chore: Update project configuration and enhance code quality
CI / skip-ci-check (push) Successful in 1m27s
CI / skip-ci-check (pull_request) Successful in 1m27s
CI / lint-and-type-check (pull_request) Has been cancelled
CI / python-lint (pull_request) Has been cancelled
CI / test-backend (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / secret-scanning (pull_request) Has been cancelled
CI / dependency-scan (pull_request) Has been cancelled
CI / sast-scan (pull_request) Has been cancelled
CI / workflow-summary (pull_request) Has been cancelled
CI / lint-and-type-check (push) Successful in 2m4s
CI / python-lint (push) Successful in 1m53s
CI / test-backend (push) Successful in 2m37s
CI / build (push) Failing after 2m13s
CI / secret-scanning (push) Successful in 1m40s
CI / dependency-scan (push) Successful in 1m34s
CI / sast-scan (push) Successful in 2m42s
CI / workflow-summary (push) Successful in 1m26s
CI / skip-ci-check (push) Successful in 1m27s
CI / skip-ci-check (pull_request) Successful in 1m27s
CI / lint-and-type-check (pull_request) Has been cancelled
CI / python-lint (pull_request) Has been cancelled
CI / test-backend (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / secret-scanning (pull_request) Has been cancelled
CI / dependency-scan (pull_request) Has been cancelled
CI / sast-scan (pull_request) Has been cancelled
CI / workflow-summary (pull_request) Has been cancelled
CI / lint-and-type-check (push) Successful in 2m4s
CI / python-lint (push) Successful in 1m53s
CI / test-backend (push) Successful in 2m37s
CI / build (push) Failing after 2m13s
CI / secret-scanning (push) Successful in 1m40s
CI / dependency-scan (push) Successful in 1m34s
CI / sast-scan (push) Successful in 2m42s
CI / workflow-summary (push) Successful in 1m26s
This commit modifies the `.gitignore` file to exclude Python library directories while ensuring the viewer-frontend's `lib` directory is not ignored. It also updates the `package.json` to activate the virtual environment during backend tests, improving the testing process. Additionally, the CI workflow is enhanced to prevent duplicate runs for branches with open pull requests. Various components in the viewer frontend are updated to ensure consistent naming conventions and improve type safety. These changes contribute to a cleaner codebase and a more efficient development workflow.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient as PrismaClientAuth } from '../node_modules/.prisma/client-auth';
|
||||
|
||||
const globalForPrisma = global as unknown as {
|
||||
prisma: PrismaClient;
|
||||
prismaWrite: PrismaClient;
|
||||
prismaAuth: PrismaClientAuth;
|
||||
};
|
||||
|
||||
// Read-only client (uses DATABASE_URL) - connects to punimtag database
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ||
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
// Write-capable client (uses DATABASE_URL_WRITE if available, otherwise DATABASE_URL)
|
||||
// This is kept for backward compatibility but should not be used for auth operations
|
||||
export const prismaWrite =
|
||||
globalForPrisma.prismaWrite ||
|
||||
new PrismaClient({
|
||||
datasourceUrl: process.env.DATABASE_URL_WRITE || process.env.DATABASE_URL,
|
||||
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
// Auth client - connects to separate punimtag_auth database (uses DATABASE_URL_AUTH)
|
||||
// This database contains users and pending_identifications tables
|
||||
export const prismaAuth =
|
||||
globalForPrisma.prismaAuth ||
|
||||
new PrismaClientAuth({
|
||||
datasourceUrl: process.env.DATABASE_URL_AUTH,
|
||||
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
globalForPrisma.prismaWrite = prismaWrite;
|
||||
globalForPrisma.prismaAuth = prismaAuth;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Resend } from 'resend';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
export function generateEmailConfirmationToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export async function sendEmailConfirmation(
|
||||
email: string,
|
||||
name: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001';
|
||||
const confirmationUrl = `${baseUrl}/api/auth/verify-email?token=${token}`;
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev',
|
||||
to: email,
|
||||
subject: 'Confirm your email address',
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Confirm your email</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 8px;">
|
||||
<h1 style="color: #2563eb; margin-top: 0;">Confirm your email address</h1>
|
||||
<p>Hi ${name},</p>
|
||||
<p>Thank you for signing up! Please confirm your email address by clicking the button below:</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${confirmationUrl}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: bold;">Confirm Email Address</a>
|
||||
</div>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #666; font-size: 14px;">${confirmationUrl}</p>
|
||||
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">
|
||||
If you didn't create an account, you can safely ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending confirmation email:', error);
|
||||
throw new Error('Failed to send confirmation email');
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmailConfirmationResend(
|
||||
email: string,
|
||||
name: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001';
|
||||
const confirmationUrl = `${baseUrl}/api/auth/verify-email?token=${token}`;
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev',
|
||||
to: email,
|
||||
subject: 'Confirm your email address',
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Confirm your email</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 8px;">
|
||||
<h1 style="color: #2563eb; margin-top: 0;">Confirm your email address</h1>
|
||||
<p>Hi ${name},</p>
|
||||
<p>You requested a new confirmation email. Please confirm your email address by clicking the button below:</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${confirmationUrl}" style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: bold;">Confirm Email Address</a>
|
||||
</div>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #666; font-size: 14px;">${confirmationUrl}</p>
|
||||
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">
|
||||
This link will expire in 24 hours. If you didn't request this email, you can safely ignore it.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error sending confirmation email:', error);
|
||||
throw new Error('Failed to send confirmation email');
|
||||
}
|
||||
}
|
||||
|
||||
export function generatePasswordResetToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
name: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001';
|
||||
const resetUrl = `${baseUrl}/reset-password?token=${token}`;
|
||||
const fromEmail = process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev';
|
||||
const replyTo = process.env.RESEND_REPLY_TO || fromEmail;
|
||||
|
||||
// Plain text version for better deliverability
|
||||
const text = `Hi ${name},
|
||||
|
||||
You requested to reset your password. Click the link below to create a new password:
|
||||
|
||||
${resetUrl}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
If you didn't request a password reset, you can safely ignore this email.
|
||||
|
||||
Best regards,
|
||||
PunimTag Viewer Team`;
|
||||
|
||||
try {
|
||||
console.log('[EMAIL] Sending password reset email:', {
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
replyTo: replyTo,
|
||||
});
|
||||
|
||||
const result = await resend.emails.send({
|
||||
from: fromEmail,
|
||||
to: email,
|
||||
replyTo: replyTo,
|
||||
subject: 'Reset your password - PunimTag Viewer',
|
||||
text: text,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>Reset your password</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #ffffff;">
|
||||
<div style="background-color: #f8f9fa; padding: 30px; border-radius: 8px; border: 1px solid #e5e7eb;">
|
||||
<h1 style="color: #2563eb; margin-top: 0; font-size: 24px;">Reset your password</h1>
|
||||
<p style="font-size: 16px;">Hi ${name},</p>
|
||||
<p style="font-size: 16px;">You requested to reset your password for your PunimTag Viewer account. Click the button below to create a new password:</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${resetUrl}" style="background-color: #2563eb; color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: bold; font-size: 16px;">Reset Password</a>
|
||||
</div>
|
||||
<p style="font-size: 14px; color: #666;">Or copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #666; font-size: 14px; background-color: #ffffff; padding: 10px; border-radius: 4px; border: 1px solid #e5e7eb;">${resetUrl}</p>
|
||||
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">
|
||||
<strong>Important:</strong> This link will expire in 1 hour for security reasons.
|
||||
</p>
|
||||
<p style="color: #6b7280; font-size: 14px;">
|
||||
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
|
||||
</p>
|
||||
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; color: #9ca3af; font-size: 12px;">
|
||||
This is an automated message from PunimTag Viewer. Please do not reply to this email.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error('[EMAIL] Resend API error:', result.error);
|
||||
throw new Error(`Resend API error: ${result.error.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
console.log('[EMAIL] Password reset email sent successfully:', {
|
||||
emailId: result.data?.id,
|
||||
to: email,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[EMAIL] Error sending password reset email:', error);
|
||||
console.error('[EMAIL] Error details:', {
|
||||
message: error?.message,
|
||||
name: error?.name,
|
||||
response: error?.response,
|
||||
statusCode: error?.statusCode,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Utilities for face detection and location parsing
|
||||
*/
|
||||
|
||||
export interface FaceLocation {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses face location string from database
|
||||
* Supports multiple formats:
|
||||
* - JSON object: {"x": 100, "y": 200, "width": 150, "height": 150}
|
||||
* - JSON array: [100, 200, 150, 150] or [x1, y1, x2, y2]
|
||||
* - Comma-separated: "100,200,150,150" (x,y,width,height or x1,y1,x2,y2)
|
||||
*/
|
||||
export function parseFaceLocation(location: string): FaceLocation | null {
|
||||
if (!location) return null;
|
||||
|
||||
// If location is already an object (shouldn't happen but handle it)
|
||||
if (typeof location === 'object' && location !== null) {
|
||||
const loc = location as any;
|
||||
if (typeof loc.x === 'number' && typeof loc.y === 'number' &&
|
||||
typeof loc.width === 'number' && typeof loc.height === 'number') {
|
||||
return { x: loc.x, y: loc.y, width: loc.width, height: loc.height };
|
||||
}
|
||||
}
|
||||
|
||||
// If it's not a string, try to convert
|
||||
const locationStr = String(location).trim();
|
||||
if (!locationStr) return null;
|
||||
|
||||
try {
|
||||
// Try JSON format first (object or array)
|
||||
const parsed = JSON.parse(locationStr);
|
||||
|
||||
// Handle JSON object: {"x": 100, "y": 200, "width": 150, "height": 150}
|
||||
// or {"x": 100, "y": 200, "w": 150, "h": 150}
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
// Handle width/height format
|
||||
if (
|
||||
typeof parsed.x === 'number' &&
|
||||
typeof parsed.y === 'number' &&
|
||||
typeof parsed.width === 'number' &&
|
||||
typeof parsed.height === 'number'
|
||||
) {
|
||||
return {
|
||||
x: parsed.x,
|
||||
y: parsed.y,
|
||||
width: parsed.width,
|
||||
height: parsed.height,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle w/h format (shorthand)
|
||||
if (
|
||||
typeof parsed.x === 'number' &&
|
||||
typeof parsed.y === 'number' &&
|
||||
typeof parsed.w === 'number' &&
|
||||
typeof parsed.h === 'number'
|
||||
) {
|
||||
return {
|
||||
x: parsed.x,
|
||||
y: parsed.y,
|
||||
width: parsed.w,
|
||||
height: parsed.h,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle object with x1, y1, x2, y2 format
|
||||
if (
|
||||
typeof parsed.x1 === 'number' &&
|
||||
typeof parsed.y1 === 'number' &&
|
||||
typeof parsed.x2 === 'number' &&
|
||||
typeof parsed.y2 === 'number'
|
||||
) {
|
||||
return {
|
||||
x: parsed.x1,
|
||||
y: parsed.y1,
|
||||
width: parsed.x2 - parsed.x1,
|
||||
height: parsed.y2 - parsed.y1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle JSON array: [x, y, width, height] or [x1, y1, x2, y2]
|
||||
if (Array.isArray(parsed) && parsed.length === 4) {
|
||||
const [a, b, c, d] = parsed.map(Number);
|
||||
if (parsed.every((n: any) => typeof n === 'number' && !isNaN(n))) {
|
||||
// Check if it's x1,y1,x2,y2 format (width/height would be negative if x2<x1 or y2<y1)
|
||||
// Or if values suggest it's coordinates rather than size
|
||||
if (c > a && d > b) {
|
||||
// Likely x1, y1, x2, y2 format
|
||||
return {
|
||||
x: a,
|
||||
y: b,
|
||||
width: c - a,
|
||||
height: d - b,
|
||||
};
|
||||
} else {
|
||||
// Likely x, y, width, height format
|
||||
return {
|
||||
x: a,
|
||||
y: b,
|
||||
width: c,
|
||||
height: d,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, try comma-separated format
|
||||
}
|
||||
|
||||
// Try comma-separated format: "x,y,width,height" or "x1,y1,x2,y2"
|
||||
const parts = locationStr.split(',').map((s) => s.trim()).map(Number);
|
||||
if (parts.length === 4 && parts.every((n) => !isNaN(n))) {
|
||||
const [a, b, c, d] = parts;
|
||||
// Check if it's x1,y1,x2,y2 format (width/height would be negative if x2<x1 or y2<y1)
|
||||
if (c > a && d > b) {
|
||||
// Likely x1, y1, x2, y2 format
|
||||
return {
|
||||
x: a,
|
||||
y: b,
|
||||
width: c - a,
|
||||
height: d - b,
|
||||
};
|
||||
} else {
|
||||
// Likely x, y, width, height format
|
||||
return {
|
||||
x: a,
|
||||
y: b,
|
||||
width: c,
|
||||
height: d,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a mouse point is within a face bounding box
|
||||
* Handles image scaling (object-fit: cover) correctly
|
||||
*/
|
||||
export function isPointInFace(
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
faceLocation: FaceLocation,
|
||||
imageNaturalWidth: number,
|
||||
imageNaturalHeight: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number
|
||||
): boolean {
|
||||
return isPointInFaceWithFit(
|
||||
mouseX,
|
||||
mouseY,
|
||||
faceLocation,
|
||||
imageNaturalWidth,
|
||||
imageNaturalHeight,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
'cover'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a mouse point is within a face bounding box
|
||||
* Handles both object-fit: cover and object-fit: contain
|
||||
*/
|
||||
export function isPointInFaceWithFit(
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
faceLocation: FaceLocation,
|
||||
imageNaturalWidth: number,
|
||||
imageNaturalHeight: number,
|
||||
containerWidth: number,
|
||||
containerHeight: number,
|
||||
objectFit: 'cover' | 'contain' = 'cover'
|
||||
): boolean {
|
||||
if (!imageNaturalWidth || !imageNaturalHeight) return false;
|
||||
|
||||
const imageAspect = imageNaturalWidth / imageNaturalHeight;
|
||||
const containerAspect = containerWidth / containerHeight;
|
||||
|
||||
let scale: number;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
if (objectFit === 'cover') {
|
||||
// object-fit: cover - image covers entire container, may be cropped
|
||||
if (imageAspect > containerAspect) {
|
||||
// Image is wider - scale based on height
|
||||
scale = containerHeight / imageNaturalHeight;
|
||||
const scaledWidth = imageNaturalWidth * scale;
|
||||
offsetX = (containerWidth - scaledWidth) / 2;
|
||||
} else {
|
||||
// Image is taller - scale based on width
|
||||
scale = containerWidth / imageNaturalWidth;
|
||||
const scaledHeight = imageNaturalHeight * scale;
|
||||
offsetY = (containerHeight - scaledHeight) / 2;
|
||||
}
|
||||
} else {
|
||||
// object-fit: contain - image fits entirely within container, may have empty space
|
||||
if (imageAspect > containerAspect) {
|
||||
// Image is wider - scale based on width
|
||||
scale = containerWidth / imageNaturalWidth;
|
||||
const scaledHeight = imageNaturalHeight * scale;
|
||||
offsetY = (containerHeight - scaledHeight) / 2;
|
||||
} else {
|
||||
// Image is taller - scale based on height
|
||||
scale = containerHeight / imageNaturalHeight;
|
||||
const scaledWidth = imageNaturalWidth * scale;
|
||||
offsetX = (containerWidth - scaledWidth) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Scale face location to container coordinates
|
||||
const scaledX = faceLocation.x * scale + offsetX;
|
||||
const scaledY = faceLocation.y * scale + offsetY;
|
||||
const scaledWidth = faceLocation.width * scale;
|
||||
const scaledHeight = faceLocation.height * scale;
|
||||
|
||||
// Check if mouse is within face bounds
|
||||
return (
|
||||
mouseX >= scaledX &&
|
||||
mouseX <= scaledX + scaledWidth &&
|
||||
mouseY >= scaledY &&
|
||||
mouseY <= scaledY + scaledHeight
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { auth } from '@/app/api/auth/[...nextauth]/route';
|
||||
import { prismaAuth } from './db';
|
||||
|
||||
/**
|
||||
* Check if the current user is an admin
|
||||
*/
|
||||
export async function isAdmin(): Promise<boolean> {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// First check if isAdmin is already in the session (faster, no DB query needed)
|
||||
if (session.user.isAdmin !== undefined) {
|
||||
return session.user.isAdmin === true;
|
||||
}
|
||||
|
||||
// Fallback to database query if session doesn't have isAdmin
|
||||
const userId = parseInt(session.user.id, 10);
|
||||
if (isNaN(userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = await prismaAuth.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { isAdmin: true, isActive: true },
|
||||
});
|
||||
|
||||
// User must be active to have admin permissions (treat null/undefined as true)
|
||||
if (user?.isActive === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return user?.isAdmin ?? false;
|
||||
} catch (error: any) {
|
||||
console.error('[isAdmin] Error checking admin status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user can approve identifications (admin only)
|
||||
*/
|
||||
export async function canApproveIdentifications(): Promise<boolean> {
|
||||
return isAdmin();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Photo } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Determines if a path is a URL (http/https) or a file system path
|
||||
*/
|
||||
export function isUrl(path: string): boolean {
|
||||
return path.startsWith('http://') || path.startsWith('https://');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if photo is a video
|
||||
*/
|
||||
export function isVideo(photo: Photo): boolean {
|
||||
// Handle both camelCase (Prisma client) and snake_case (direct DB access)
|
||||
return (photo as any).mediaType === 'video' || (photo as any).media_type === 'video';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate image source URL
|
||||
* - URLs (SharePoint, CDN, etc.) → use directly
|
||||
* - File system paths → use API proxy
|
||||
* - Videos → use thumbnail endpoint (for grid display)
|
||||
*/
|
||||
export function getImageSrc(photo: Photo, options?: { watermark?: boolean; thumbnail?: boolean }): string {
|
||||
// For videos, use thumbnail endpoint if requested (for grid display)
|
||||
if (options?.thumbnail && isVideo(photo)) {
|
||||
return `/api/photos/${photo.id}/image?thumbnail=true`;
|
||||
}
|
||||
|
||||
if (isUrl(photo.path)) {
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
||||
console.log(`✅ Photo ${photo.id}: Using DIRECT access for URL:`, photo.path);
|
||||
}
|
||||
return photo.path;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (options?.watermark) {
|
||||
params.set('watermark', 'true');
|
||||
}
|
||||
const query = params.toString();
|
||||
|
||||
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
||||
console.log(`📁 Photo ${photo.id}: Using API PROXY for file path:`, photo.path);
|
||||
}
|
||||
|
||||
return `/api/photos/${photo.id}/image${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate video source URL
|
||||
*/
|
||||
export function getVideoSrc(photo: Photo): string {
|
||||
if (isUrl(photo.path)) {
|
||||
return photo.path;
|
||||
}
|
||||
return `/api/photos/${photo.id}/image`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { prisma } from './db';
|
||||
|
||||
export interface SearchFilters {
|
||||
people?: number[];
|
||||
tags?: number[];
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
mediaType?: 'all' | 'photos' | 'videos';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export async function searchPhotos(filters: SearchFilters) {
|
||||
const {
|
||||
people = [],
|
||||
tags = [],
|
||||
dateFrom,
|
||||
dateTo,
|
||||
mediaType = 'all',
|
||||
page = 1,
|
||||
pageSize = 30,
|
||||
} = filters;
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
// Build where clause
|
||||
const where: any = {
|
||||
processed: true,
|
||||
};
|
||||
|
||||
// Media type filter
|
||||
if (mediaType !== 'all') {
|
||||
if (mediaType === 'photos') {
|
||||
where.media_type = 'image';
|
||||
} else if (mediaType === 'videos') {
|
||||
where.media_type = 'video';
|
||||
}
|
||||
}
|
||||
|
||||
// Date filter
|
||||
if (dateFrom || dateTo) {
|
||||
where.date_taken = {};
|
||||
if (dateFrom) {
|
||||
where.date_taken.gte = dateFrom;
|
||||
}
|
||||
if (dateTo) {
|
||||
where.date_taken.lte = dateTo;
|
||||
}
|
||||
}
|
||||
|
||||
// People filter (photo has face with person_id in list)
|
||||
if (people.length > 0) {
|
||||
where.faces = {
|
||||
some: {
|
||||
personId: { in: people },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if (tags.length > 0) {
|
||||
where.PhotoTagLinkage = {
|
||||
some: {
|
||||
tagId: { in: tags },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Execute query
|
||||
const [photos, total] = await Promise.all([
|
||||
prisma.photo.findMany({
|
||||
where,
|
||||
include: {
|
||||
Face: {
|
||||
include: {
|
||||
Person: true,
|
||||
},
|
||||
},
|
||||
PhotoTagLinkage: {
|
||||
include: {
|
||||
Tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date_taken: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.photo.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
photos,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllPeople() {
|
||||
return prisma.person.findMany({
|
||||
orderBy: [
|
||||
{ first_name: 'asc' },
|
||||
{ last_name: 'asc' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllTags() {
|
||||
return prisma.tag.findMany({
|
||||
orderBy: { tag_name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Serialization utilities for converting Prisma objects to plain JavaScript objects
|
||||
* that can be safely passed from Server Components to Client Components in Next.js.
|
||||
*
|
||||
* Handles:
|
||||
* - Decimal objects -> numbers
|
||||
* - Date objects -> ISO strings
|
||||
* - Nested structures (photos, faces, people, tags)
|
||||
*/
|
||||
|
||||
type Decimal = {
|
||||
toNumber(): number;
|
||||
toString(): string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a value is a Prisma Decimal object
|
||||
*/
|
||||
function isDecimal(value: any): value is Decimal {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
typeof value.toNumber === 'function' &&
|
||||
typeof value.toString === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Decimal to a number, handling null/undefined
|
||||
*/
|
||||
function decimalToNumber(value: any): number | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (isDecimal(value)) {
|
||||
return value.toNumber();
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
// Fallback: try to parse as number
|
||||
const parsed = Number(value);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a Date object to an ISO string
|
||||
*/
|
||||
function serializeDate(value: any): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a Person object
|
||||
*/
|
||||
function serializePerson(person: any): any {
|
||||
if (!person) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
first_name: person.first_name,
|
||||
last_name: person.last_name,
|
||||
middle_name: person.middle_name ?? null,
|
||||
maiden_name: person.maiden_name ?? null,
|
||||
date_of_birth: serializeDate(person.date_of_birth),
|
||||
created_date: serializeDate(person.created_date),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a Face object, converting Decimal fields to numbers
|
||||
*/
|
||||
function serializeFace(face: any): any {
|
||||
if (!face) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serialized: any = {
|
||||
id: face.id,
|
||||
photo_id: face.photo_id,
|
||||
person_id: face.person_id ?? null,
|
||||
location: face.location,
|
||||
confidence: decimalToNumber(face.confidence) ?? 0,
|
||||
quality_score: decimalToNumber(face.quality_score) ?? 0,
|
||||
is_primary_encoding: face.is_primary_encoding ?? false,
|
||||
detector_backend: face.detector_backend,
|
||||
model_name: face.model_name,
|
||||
face_confidence: decimalToNumber(face.face_confidence) ?? 0,
|
||||
exif_orientation: face.exif_orientation ?? null,
|
||||
pose_mode: face.pose_mode,
|
||||
yaw_angle: decimalToNumber(face.yaw_angle),
|
||||
pitch_angle: decimalToNumber(face.pitch_angle),
|
||||
roll_angle: decimalToNumber(face.roll_angle),
|
||||
landmarks: face.landmarks ?? null,
|
||||
identified_by_user_id: face.identified_by_user_id ?? null,
|
||||
excluded: face.excluded ?? false,
|
||||
};
|
||||
|
||||
// Handle nested Person object (if present)
|
||||
if (face.Person) {
|
||||
serialized.Person = serializePerson(face.Person);
|
||||
} else if (face.person) {
|
||||
serialized.person = serializePerson(face.person);
|
||||
}
|
||||
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a Tag object
|
||||
*/
|
||||
function serializeTag(tag: any): any {
|
||||
if (!tag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: tag.id,
|
||||
tagName: tag.tag_name || tag.tagName,
|
||||
created_date: serializeDate(tag.created_date),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a PhotoTagLinkage object
|
||||
*/
|
||||
function serializePhotoTagLinkage(linkage: any): any {
|
||||
if (!linkage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serialized: any = {
|
||||
linkage_id: linkage.linkage_id ?? linkage.id,
|
||||
photo_id: linkage.photo_id,
|
||||
tag_id: linkage.tag_id,
|
||||
linkage_type: linkage.linkage_type ?? 0,
|
||||
created_date: serializeDate(linkage.created_date),
|
||||
};
|
||||
|
||||
// Handle nested Tag object (if present)
|
||||
if (linkage.Tag || linkage.tag) {
|
||||
serialized.tag = serializeTag(linkage.Tag || linkage.tag);
|
||||
// Also keep Tag for backward compatibility
|
||||
serialized.Tag = serialized.tag;
|
||||
}
|
||||
if (linkage.Tag || linkage.tag) {
|
||||
// Also keep Tag for backward compatibility
|
||||
serialized.Tag = serialized.tag;
|
||||
}
|
||||
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a single Photo object with all nested structures
|
||||
*/
|
||||
export function serializePhoto(photo: any): any {
|
||||
if (!photo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serialized: any = {
|
||||
id: photo.id,
|
||||
path: photo.path,
|
||||
filename: photo.filename,
|
||||
date_added: serializeDate(photo.date_added),
|
||||
date_taken: serializeDate(photo.date_taken),
|
||||
processed: photo.processed ?? false,
|
||||
media_type: photo.media_type ?? null,
|
||||
};
|
||||
|
||||
// Handle Face array (can be named Face or faces)
|
||||
if (photo.Face && Array.isArray(photo.Face)) {
|
||||
serialized.Face = photo.Face.map((face: any) => serializeFace(face));
|
||||
} else if (photo.faces && Array.isArray(photo.faces)) {
|
||||
serialized.faces = photo.faces.map((face: any) => serializeFace(face));
|
||||
}
|
||||
|
||||
// Handle PhotoTagLinkage array (can be named PhotoTagLinkage or photoTags)
|
||||
if (photo.PhotoTagLinkage && Array.isArray(photo.PhotoTagLinkage)) {
|
||||
serialized.PhotoTagLinkage = photo.PhotoTagLinkage.map((linkage: any) =>
|
||||
serializePhotoTagLinkage(linkage)
|
||||
);
|
||||
} else if (photo.photoTags && Array.isArray(photo.photoTags)) {
|
||||
serialized.photoTags = photo.photoTags.map((linkage: any) =>
|
||||
serializePhotoTagLinkage(linkage)
|
||||
);
|
||||
}
|
||||
|
||||
return serialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an array of Photo objects
|
||||
*/
|
||||
export function serializePhotos(photos: any[]): any[] {
|
||||
if (!Array.isArray(photos)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return photos.map((photo) => serializePhoto(photo));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an array of Person objects
|
||||
*/
|
||||
export function serializePeople(people: any[]): any[] {
|
||||
if (!Array.isArray(people)) {
|
||||
return [];
|
||||
}
|
||||
return people.map((person) => serializePerson(person));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an array of Tag objects
|
||||
*/
|
||||
export function serializeTags(tags: any[]): any[] {
|
||||
if (!Array.isArray(tags)) {
|
||||
return [];
|
||||
}
|
||||
return tags.map((tag) => serializeTag(tag));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an email address format
|
||||
* @param email - The email address to validate
|
||||
* @returns true if the email is valid, false otherwise
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
const trimmedEmail = email.trim();
|
||||
|
||||
// Basic email regex pattern
|
||||
// Matches: local-part@domain
|
||||
// - Local part: alphanumeric, dots, hyphens, underscores, plus signs
|
||||
// - Domain: alphanumeric, dots, hyphens
|
||||
// - Must have @ symbol
|
||||
// - Domain must have at least one dot
|
||||
const emailRegex = /^[a-zA-Z0-9._+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
|
||||
// Check length constraints (RFC 5321)
|
||||
if (trimmedEmail.length > 254) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for valid format
|
||||
if (!emailRegex.test(trimmedEmail)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional checks
|
||||
// - Cannot start or end with dot
|
||||
// - Cannot have consecutive dots
|
||||
// - Must have valid domain part
|
||||
const parts = trimmedEmail.split('@');
|
||||
if (parts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [localPart, domain] = parts;
|
||||
|
||||
// Validate local part
|
||||
if (localPart.length === 0 || localPart.length > 64) {
|
||||
return false;
|
||||
}
|
||||
if (localPart.startsWith('.') || localPart.endsWith('.')) {
|
||||
return false;
|
||||
}
|
||||
if (localPart.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate domain part
|
||||
if (domain.length === 0 || domain.length > 253) {
|
||||
return false;
|
||||
}
|
||||
if (domain.startsWith('.') || domain.endsWith('.')) {
|
||||
return false;
|
||||
}
|
||||
if (domain.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
if (!domain.includes('.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Domain must have at least one TLD (top-level domain)
|
||||
const domainParts = domain.split('.');
|
||||
if (domainParts.length < 2) {
|
||||
return false;
|
||||
}
|
||||
const tld = domainParts[domainParts.length - 1];
|
||||
if (tld.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Thumbnail cache directory (relative to project root)
|
||||
const THUMBNAIL_CACHE_DIR = path.join(process.cwd(), '.cache', 'video-thumbnails');
|
||||
const THUMBNAIL_QUALITY = 85; // JPEG quality
|
||||
const THUMBNAIL_MAX_WIDTH = 800; // Max width for thumbnails
|
||||
|
||||
// Ensure cache directory exists
|
||||
function ensureCacheDir() {
|
||||
if (!existsSync(THUMBNAIL_CACHE_DIR)) {
|
||||
mkdirSync(THUMBNAIL_CACHE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail path for a video file
|
||||
*/
|
||||
function getThumbnailPath(videoPath: string): string {
|
||||
ensureCacheDir();
|
||||
// Create a hash-like filename from the video path
|
||||
// Use a simple approach: replace path separators and special chars
|
||||
const safePath = videoPath.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const hash = Buffer.from(safePath).toString('base64').slice(0, 32);
|
||||
return path.join(THUMBNAIL_CACHE_DIR, `${hash}.jpg`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ffmpeg is available
|
||||
*/
|
||||
async function isFfmpegAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execAsync('ffmpeg -version');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate video thumbnail using ffmpeg
|
||||
* Extracts frame at 1 second (or first frame if video is shorter)
|
||||
*/
|
||||
async function generateThumbnailWithFfmpeg(
|
||||
videoPath: string,
|
||||
thumbnailPath: string
|
||||
): Promise<Buffer> {
|
||||
// Escape paths properly for shell commands
|
||||
const escapedVideoPath = videoPath.replace(/'/g, "'\"'\"'");
|
||||
const escapedThumbnailPath = thumbnailPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
// Extract frame at 1 second, or first frame if video is shorter
|
||||
// Scale to max width while maintaining aspect ratio
|
||||
// Use -loglevel error to suppress ffmpeg output unless there's an error
|
||||
const command = `ffmpeg -i '${escapedVideoPath}' -ss 00:00:01 -vframes 1 -vf "scale=${THUMBNAIL_MAX_WIDTH}:-1" -q:v ${THUMBNAIL_QUALITY} -y '${escapedThumbnailPath}' -loglevel error`;
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, { maxBuffer: 10 * 1024 * 1024 });
|
||||
|
||||
if (stderr && !stderr.includes('frame=')) {
|
||||
console.warn('ffmpeg stderr:', stderr);
|
||||
}
|
||||
|
||||
// Wait a bit for file to be written
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Read the generated thumbnail
|
||||
if (existsSync(thumbnailPath)) {
|
||||
const buffer = await readFile(thumbnailPath);
|
||||
if (buffer.length > 0) {
|
||||
return buffer;
|
||||
}
|
||||
throw new Error('Thumbnail file is empty');
|
||||
}
|
||||
throw new Error('Thumbnail file was not created');
|
||||
} catch (error) {
|
||||
console.error(`Error extracting frame at 1s for ${videoPath}:`, error);
|
||||
|
||||
// If extraction at 1s fails, try first frame
|
||||
try {
|
||||
const fallbackCommand = `ffmpeg -i '${escapedVideoPath}' -vframes 1 -vf "scale=${THUMBNAIL_MAX_WIDTH}:-1" -q:v ${THUMBNAIL_QUALITY} -y '${escapedThumbnailPath}' -loglevel error`;
|
||||
const { stdout, stderr } = await execAsync(fallbackCommand, { maxBuffer: 10 * 1024 * 1024 });
|
||||
|
||||
if (stderr && !stderr.includes('frame=')) {
|
||||
console.warn('ffmpeg fallback stderr:', stderr);
|
||||
}
|
||||
|
||||
// Wait a bit for file to be written
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
if (existsSync(thumbnailPath)) {
|
||||
const buffer = await readFile(thumbnailPath);
|
||||
if (buffer.length > 0) {
|
||||
return buffer;
|
||||
}
|
||||
throw new Error('Fallback thumbnail file is empty');
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error(`Error generating video thumbnail (fallback failed) for ${videoPath}:`, fallbackError);
|
||||
throw new Error(`Failed to generate video thumbnail: ${fallbackError instanceof Error ? fallbackError.message : 'Unknown error'}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate or retrieve cached video thumbnail
|
||||
* Returns thumbnail buffer if successful, null if ffmpeg is not available
|
||||
*/
|
||||
export async function getVideoThumbnail(videoPath: string): Promise<Buffer | null> {
|
||||
// Check if ffmpeg is available
|
||||
const ffmpegAvailable = await isFfmpegAvailable();
|
||||
if (!ffmpegAvailable) {
|
||||
console.warn('ffmpeg is not available. Video thumbnails will not be generated.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const thumbnailPath = getThumbnailPath(videoPath);
|
||||
|
||||
// Check if thumbnail already exists in cache
|
||||
if (existsSync(thumbnailPath)) {
|
||||
try {
|
||||
return await readFile(thumbnailPath);
|
||||
} catch (error) {
|
||||
console.error('Error reading cached thumbnail:', error);
|
||||
// Continue to regenerate
|
||||
}
|
||||
}
|
||||
|
||||
// Check if video file exists
|
||||
if (!existsSync(videoPath)) {
|
||||
console.error(`Video file not found: ${videoPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generate new thumbnail
|
||||
try {
|
||||
const thumbnailBuffer = await generateThumbnailWithFfmpeg(videoPath, thumbnailPath);
|
||||
return thumbnailBuffer;
|
||||
} catch (error) {
|
||||
console.error(`Error generating thumbnail for ${videoPath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a thumbnail exists in cache
|
||||
*/
|
||||
export function hasCachedThumbnail(videoPath: string): boolean {
|
||||
const thumbnailPath = getThumbnailPath(videoPath);
|
||||
return existsSync(thumbnailPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached thumbnail path (for direct file serving)
|
||||
*/
|
||||
export function getCachedThumbnailPath(videoPath: string): string | null {
|
||||
const thumbnailPath = getThumbnailPath(videoPath);
|
||||
return existsSync(thumbnailPath) ? thumbnailPath : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user