Initial commit

This commit is contained in:
twotalesanimation
2026-06-11 10:46:09 +02:00
commit 81ad7e4ea9
223 changed files with 39530 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
// 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';
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 }
);
}
}
// Helper function to calculate unique watched seconds from segments
function calculateWatchedSeconds(segments: Array<{ startSec: number; endSec: number }>): number {
if (segments.length === 0) return 0;
// Sort and merge overlapping ranges
const sorted = segments.sort((a, b) => a.startSec - b.startSec);
const merged: Array<[number, number]> = [];
for (const seg of sorted) {
if (merged.length === 0) {
merged.push([seg.startSec, seg.endSec]);
} else {
const last = merged[merged.length - 1];
if (seg.startSec <= last[1] + 0.5) {
// Overlapping or adjacent, merge
last[1] = Math.max(last[1], seg.endSec);
} else {
// Gap, new range
merged.push([seg.startSec, seg.endSec]);
}
}
}
let total = 0;
for (const [start, end] of merged) {
total += Math.max(0, end - start);
}
return Math.round(total);
}
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, playlistId, watchedSec, lastPos, duration } = body ?? {};
if (!videoId)
return NextResponse.json({ error: 'videoId required' }, { status: 400 });
if (!duration || Number(duration) <= 0)
return NextResponse.json({ error: 'duration required' }, { status: 400 });
const watched = Number(watchedSec ?? 0);
const lastPosition = Number(lastPos ?? 0);
const dur = Number(duration);
// look up user id
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) {
// 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 });
}
// Ensure VideoProgress exists or create it
let videoProgress = await prisma.videoProgress.findUnique({
where: { userId_videoId: { userId: user.id, videoId } },
});
if (!videoProgress) {
videoProgress = await prisma.videoProgress.create({
data: {
userId: user.id,
videoId,
watchedSec: 0,
lastPos: 0,
percent: 0,
durationSec: dur,
},
});
}
// Record the watch segment if watched seconds > 0
let newSegment = null;
if (watched > 0) {
newSegment = await prisma.videoWatchSegment.create({
data: {
userId: user.id,
videoId,
startSec: Math.max(0, lastPosition - watched),
endSec: lastPosition,
},
});
}
// Fetch all segments for this user+video to recalculate totals
const allSegments = await prisma.videoWatchSegment.findMany({
where: { userId: user.id, videoId },
select: { startSec: true, endSec: true },
orderBy: { createdAt: 'asc' },
});
// Calculate total unique watched seconds
const totalWatchedSec = calculateWatchedSeconds(allSegments);
// Calculate completion percentage
const ratio = dur > 0 ? totalWatchedSec / dur : 0;
const percentInt = Math.min(100, Math.round(ratio * 100));
const completed = percentInt >= 80; // Changed from 90 to 80 for unlock threshold
// Update VideoProgress with recalculated values
const upserted = await prisma.videoProgress.update({
where: { userId_videoId: { userId: user.id, videoId } },
data: {
watchedSec: totalWatchedSec,
lastPos: Math.max(videoProgress.lastPos ?? 0, lastPosition),
percent: percentInt,
durationSec: dur,
completed,
updatedAt: new Date(),
},
});
// If 80% watched, unlock next video in playlist for this user
let unlockedNext = null;
if (completed && playlistId) {
// find current video
const current = await prisma.video.findUnique({ where: { id: videoId } });
if (current && current.playlistId === playlistId) {
// Find next video that is NOT instant access and NOT globally locked
// Skip any instant access videos in the sequence
const nextVideos = await prisma.video.findMany({
where: {
playlistId,
index: { gt: current.index },
locked: false,
instantAccess: false,
},
orderBy: { index: 'asc' },
take: 1,
});
if (nextVideos.length > 0) {
const next = nextVideos[0];
// Create a VideoUnlock record for this user (per-user unlock tracking)
const unlock = await prisma.videoUnlock.upsert({
where: { userId_videoId: { userId: user.id, videoId: next.id } },
update: {}, // if already exists, do nothing
create: { userId: user.id, videoId: next.id },
});
unlockedNext = { id: next.id, title: next.title };
}
}
}
return NextResponse.json({ ok: true, progress: upserted, unlockedNext, segment: newSegment });
} catch (err: any) {
console.error('progress error', err);
// Handle foreign key constraint violations
if (err.code === 'P2003') {
const constraint = err.meta?.constraint_name;
if (constraint?.includes('videoId')) {
console.error(`[PROGRESS] Foreign key violation - invalid videoId: ${err.meta}`);
return NextResponse.json(
{ error: 'Invalid video reference' },
{ status: 400 }
);
}
}
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
import { prisma } from '@/lib/prisma';
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 for segments: ${videoId} (likely deleted video with cached browser reference)`);
return NextResponse.json({ error: 'video not found' }, { status: 404 });
}
// Fetch all watch segments for this user+video
const segments = await prisma.videoWatchSegment.findMany({
where: { userId: user.id, videoId },
select: { startSec: true, endSec: true, watchedAt: true },
orderBy: { createdAt: 'asc' },
});
return NextResponse.json({ segments });
} catch (err: any) {
console.error('segments GET error', err);
return NextResponse.json(
{ error: err?.message ?? 'server error' },
{ status: 500 }
);
}
}