// app/api/progress/route.ts import { NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth-options'; import { prisma } from '@/lib/prisma'; import { UNLOCK_THRESHOLD_PERCENT, mergeRanges, sanitizeRanges, totalSeconds, unlockNextVideo, type SecRange, } from '@/lib/watch-progress'; export async function GET(req: Request) { try { const session = await getServerSession(authOptions); if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const userEmail = session.user.email; const { searchParams } = new URL(req.url); const videoId = searchParams.get('videoId'); if (!videoId) return NextResponse.json({ error: 'videoId required' }, { status: 400 }); const user = await prisma.user.findUnique({ where: { email: userEmail } }); if (!user) return NextResponse.json({ error: 'user not found' }, { status: 404 }); // Validate that the video exists const video = await prisma.video.findUnique({ where: { id: videoId } }); if (!video) { console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`); return NextResponse.json({ error: 'video not found' }, { status: 404 }); } const progress = await prisma.videoProgress.findUnique({ where: { userId_videoId: { userId: user.id, videoId } }, select: { percent: true, lastPos: true, watchedSec: true }, }); return NextResponse.json({ percent: progress?.percent ?? 0, lastPos: progress?.lastPos ?? 0, watchedSec: progress?.watchedSec ?? 0, }); } catch (err: any) { console.error('progress GET error', err); return NextResponse.json( { error: err?.message ?? 'server error' }, { status: 500 } ); } } export async function POST(req: Request) { try { const session = await getServerSession(authOptions); if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const userEmail = session.user.email; const body = await req.json().catch(() => ({})); const { videoId, watchedSec, lastPos, duration } = body ?? {}; if (!videoId || typeof videoId !== 'string') return NextResponse.json({ error: 'videoId required' }, { status: 400 }); // look up user id const user = await prisma.user.findUnique({ where: { email: userEmail }, select: { id: true }, }); if (!user) return NextResponse.json({ error: 'user not found' }, { status: 404 }); // Validate that the video exists const video = await prisma.video.findUnique({ where: { id: videoId }, select: { id: true, playlistId: true, durationSec: true }, }); if (!video) { // Log at debug level since this is expected when users have old cached references console.debug(`[PROGRESS] Video not found: ${videoId} (likely deleted video with cached browser reference)`); return NextResponse.json({ error: 'video not found' }, { status: 404 }); } // The stored duration wins; the player's value is only a fallback for // videos uploaded without one. const dur = video.durationSec && video.durationSec > 0 ? video.durationSec : Number(duration); if (!Number.isFinite(dur) || dur <= 0) return NextResponse.json({ error: 'duration required' }, { status: 400 }); const lastPosition = Math.max(0, Math.floor(Number(lastPos ?? 0)) || 0); // Current players send the ranges actually played. Tabs still running the // previous player only send a total, from which one range is inferred. let incoming: SecRange[]; if (Array.isArray(body?.ranges)) { incoming = sanitizeRanges(body.ranges, dur); } else { const watched = Number(watchedSec ?? 0); incoming = sanitizeRanges( watched > 0 ? [[lastPosition - watched, lastPosition]] : [], dur ); } const where = { userId_videoId: { userId: user.id, videoId } }; const { progress, segments } = await prisma.$transaction(async (tx) => { const existingProgress = await tx.videoProgress.upsert({ where, update: {}, create: { userId: user.id, videoId, watchedSec: 0, lastPos: 0, percent: 0, durationSec: Math.round(dur), }, }); const existing = await tx.videoWatchSegment.findMany({ where: { userId: user.id, videoId }, select: { startSec: true, endSec: true, watchedAt: true }, orderBy: { startSec: 'asc' }, }); // Keep one row per distinct watched stretch instead of one per update. const now = new Date(); const merged = mergeRanges([ ...existing, ...incoming.map((r) => ({ ...r, watchedAt: now })), ]); const unchanged = merged.length === existing.length && merged.every( (m, i) => m.startSec === existing[i].startSec && m.endSec === existing[i].endSec ); if (!unchanged) { await tx.videoWatchSegment.deleteMany({ where: { userId: user.id, videoId } }); await tx.videoWatchSegment.createMany({ data: merged.map((m) => ({ userId: user.id, videoId, startSec: m.startSec, endSec: m.endSec, watchedAt: m.watchedAt ?? now, })), }); } // Progress never goes backwards, even if segment rows were pruned. const mergedSec = totalSeconds(merged); const percent = Math.max( existingProgress.percent, Math.min(100, Math.round((mergedSec / dur) * 100)) ); const completed = existingProgress.completed || percent >= UNLOCK_THRESHOLD_PERCENT; const updated = await tx.videoProgress.update({ where, data: { watchedSec: Math.max(existingProgress.watchedSec, mergedSec), lastPos: Math.max(existingProgress.lastPos ?? 0, lastPosition), percent, durationSec: Math.round(dur), completed, updatedAt: now, }, }); return { progress: updated, segments: merged }; }); // Completing a video unlocks the next one in its playlist for this user. const unlockedNext = progress.completed ? await unlockNextVideo(user.id, video) : null; return NextResponse.json({ ok: true, progress, unlockedNext, segments }); } catch (err: any) { console.error('progress error', err); return NextResponse.json( { error: err?.message ?? 'server error' }, { status: 500 } ); } }