PunimTag Web Application - Major Feature Release #1

Open
tanyar09 wants to merge 106 commits from dev into master
26 changed files with 1311 additions and 67 deletions
Showing only changes of commit b6a9765315 - Show all commits

View File

@ -7,6 +7,12 @@ on:
pull_request:
types: [opened, synchronize, reopened]
# Prevent duplicate runs when pushing to a branch with an open PR
# This ensures only one workflow runs at a time for the same branch/PR
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
# Check if CI should be skipped based on branch name or commit message
skip-ci-check:

2
.gitignore vendored
View File

@ -10,7 +10,9 @@ dist/
downloads/
eggs/
.eggs/
# Python lib directories (but not viewer-frontend/lib/)
lib/
!viewer-frontend/lib/
lib64/
parts/
sdist/

View File

@ -74,7 +74,7 @@ PunimTag is a modern web-based photo management and tagging application with adv
│ │ • /api/v1/users • /api/v1/videos │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ BUSINESS LOGIC LAYER │
│ ┌──────────────────┬──────────────────┬──────────────────────────┐ │
@ -92,7 +92,7 @@ PunimTag is a modern web-based photo management and tagging application with adv
│ │ (Multi-criteria)│ (Video Processing)│ (JWT, RBAC) │ │
│ └──────────────────┴──────────────────┴──────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ DATA ACCESS LAYER │
│ ┌──────────────────────────────────────────────────────────────────┐ │
@ -103,7 +103,7 @@ PunimTag is a modern web-based photo management and tagging application with adv
│ │ • Query optimization • Data integrity │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ PERSISTENCE LAYER │
│ ┌──────────────────────────────┬──────────────────────────────────┐ │

View File

@ -17,7 +17,7 @@
"type-check:viewer": "npm run type-check --prefix viewer-frontend",
"lint:python": "flake8 backend --max-line-length=100 --ignore=E501,W503 || true",
"lint:python:syntax": "find backend -name '*.py' -exec python -m py_compile {} \\;",
"test:backend": "export PYTHONPATH=$(pwd) && python -m pytest tests/ -v",
"test:backend": "export PYTHONPATH=$(pwd) && source venv/bin/activate && python3 -m pytest tests/ -v || python3 -m pytest tests/ -v",
"test:all": "npm run test:backend",
"ci:local": "npm run lint:all && npm run type-check:viewer && npm run lint:python && npm run test:backend && npm run build:all",
"deploy:dev": "npm run build:all && echo '✅ Build complete. Ready for deployment to dev server (10.0.10.121)'",

View File

@ -248,9 +248,9 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent
// Photo is already loaded, use it directly - no database access!
console.log('[HomePageContent] Using existing photo from photos array:', {
photoId: existingPhoto.id,
hasFaces: !!existingPhoto.faces,
hasFaces: !!(existingPhoto as any).faces,
hasFace: !!(existingPhoto as any).Face,
facesCount: existingPhoto.faces?.length || (existingPhoto as any).Face?.length || 0,
facesCount: (existingPhoto as any).faces?.length || (existingPhoto as any).Face?.length || 0,
photoKeys: Object.keys(existingPhoto),
});
setModalPhoto(existingPhoto);

View File

@ -1,13 +1,13 @@
'use client';
import { useState } from 'react';
import { useState, Suspense } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
export default function LoginPage() {
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl') || '/';
@ -213,3 +213,22 @@ export default function LoginPage() {
);
}
export default function LoginPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">Loading...</p>
</div>
</div>
</div>
}>
<LoginForm />
</Suspense>
);
}

View File

@ -1,3 +1,4 @@
import { Suspense } from 'react';
import { prisma } from '@/lib/db';
import { HomePageContent } from './HomePageContent';
import { Photo } from '@prisma/client';
@ -229,7 +230,15 @@ export default async function HomePage() {
</p>
</div>
) : (
<HomePageContent initialPhotos={serializePhotos(photos)} people={serializePeople(people)} tags={serializeTags(tags)} />
<Suspense fallback={
<div className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
</div>
}>
<HomePageContent initialPhotos={serializePhotos(photos)} people={serializePeople(people)} tags={serializeTags(tags)} />
</Suspense>
)}
</main>
);

View File

@ -8,14 +8,14 @@ async function getPhoto(id: number) {
const photo = await prisma.photo.findUnique({
where: { id },
include: {
faces: {
Face: {
include: {
person: true,
Person: true,
},
},
photoTags: {
PhotoTagLinkage: {
include: {
tag: true,
Tag: true,
},
},
},
@ -36,18 +36,18 @@ async function getPhotosByIds(ids: number[]) {
processed: true,
},
include: {
faces: {
Face: {
include: {
person: true,
Person: true,
},
},
photoTags: {
PhotoTagLinkage: {
include: {
tag: true,
Tag: true,
},
},
},
orderBy: { dateTaken: 'desc' },
orderBy: { date_taken: 'desc' },
});
return serializePhotos(photos);

View File

@ -1,12 +1,12 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
export default function ResetPasswordPage() {
function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get('token');
@ -177,6 +177,25 @@ export default function ResetPasswordPage() {
);
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md space-y-8">
<div className="text-center">
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-secondary">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">Loading...</p>
</div>
</div>
</div>
}>
<ResetPasswordForm />
</Suspense>
);
}

View File

@ -25,13 +25,16 @@ async function getAllPeople() {
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted person data detected, attempting fallback query');
try {
// Try with minimal fields first
// Try with minimal fields first, but include all required fields for type compatibility
return await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
// Exclude potentially corrupted optional fields
middle_name: true,
maiden_name: true,
date_of_birth: true,
created_date: true,
},
orderBy: [
{ first_name: 'asc' },
@ -64,12 +67,12 @@ async function getAllTags() {
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted tag data detected, attempting fallback query');
try {
// Try with minimal fields
// Try with minimal fields, but include all required fields for type compatibility
return await prisma.tag.findMany({
select: {
id: true,
tag_name: true,
// Exclude potentially corrupted date field
created_date: true,
},
orderBy: { tag_name: 'asc' },
});

View File

@ -17,10 +17,9 @@ export default function TestImagesPage() {
id: 9991,
path: 'https://picsum.photos/800/600?random=1',
filename: 'test-direct-url-1.jpg',
dateAdded: new Date(),
dateTaken: null,
date_added: new Date(),
date_taken: null,
processed: true,
file_hash: 'test-hash-1',
media_type: 'image',
},
// Test 2: Another direct URL
@ -28,10 +27,9 @@ export default function TestImagesPage() {
id: 9992,
path: 'https://picsum.photos/800/600?random=2',
filename: 'test-direct-url-2.jpg',
dateAdded: new Date(),
dateTaken: null,
date_added: new Date(),
date_taken: null,
processed: true,
file_hash: 'test-hash-2',
media_type: 'image',
},
// Test 3: File system path (will use API proxy)
@ -39,10 +37,9 @@ export default function TestImagesPage() {
id: 9993,
path: '/nonexistent/path/test.jpg',
filename: 'test-file-system.jpg',
dateAdded: new Date(),
dateTaken: null,
date_added: new Date(),
date_taken: null,
processed: true,
file_hash: 'test-hash-3',
media_type: 'image',
},
];

View File

@ -72,12 +72,12 @@ export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) {
router.back();
};
const peopleNames = photo.faces
?.map((face) => face.person)
.filter((person): person is Person => person !== null)
.map((person) => `${person.firstName} ${person.lastName}`.trim()) || [];
const peopleNames = (photo as any).faces
?.map((face: any) => face.Person)
.filter((person: any): person is Person => person !== null)
.map((person: Person) => `${person.first_name} ${person.last_name}`.trim()) || [];
const tags = photo.photoTags?.map((pt) => pt.tag.tagName) || [];
const tags = (photo as any).PhotoTagLinkage?.map((pt: any) => pt.Tag.tag_name) || [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black">
@ -143,9 +143,9 @@ export function PhotoViewer({ photo, previousId, nextId }: PhotoViewerProps) {
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-6 text-white">
<div className="container mx-auto">
<h2 className="text-xl font-semibold mb-2">{photo.filename}</h2>
{photo.dateTaken && (
{photo.date_taken && (
<p className="text-sm text-gray-300 mb-2">
{new Date(photo.dateTaken).toLocaleDateString('en-US', {
{new Date(photo.date_taken).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',

View File

@ -758,9 +758,10 @@ export function PhotoViewerClient({
const face = findFaceAtPoint(e.clientX, e.clientY);
if (face) {
const personName = face.person
? `${face.person.firstName} ${face.person.lastName}`.trim()
: null;
const person = face.person as any;
const firstName = person?.first_name || person?.firstName;
const lastName = person?.last_name || person?.lastName;
const personName = firstName && lastName ? `${firstName} ${lastName}`.trim() : null;
console.log('[PhotoViewerClient] handleMouseMove: Face detected on hover', {
faceId: face.id,
@ -1072,12 +1073,12 @@ export function PhotoViewerClient({
}
};
const peopleNames = currentPhoto.faces
?.map((face) => face.person)
.filter((person): person is Person => person !== null)
.map((person) => `${person.firstName} ${person.lastName}`.trim()) || [];
const peopleNames = (currentPhoto as any).faces
?.map((face: any) => face.Person)
.filter((person: any): person is Person => person !== null)
.map((person: Person) => `${person.first_name} ${person.last_name}`.trim()) || [];
const tags = currentPhoto.photoTags?.map((pt) => pt.tag.tagName) || [];
const tags = (currentPhoto as any).PhotoTagLinkage?.map((pt: any) => pt.Tag.tag_name) || [];
const hasPrevious = allPhotos.length > 0 && currentIdx > 0;
const hasNext = allPhotos.length > 0 && currentIdx < allPhotos.length - 1;
@ -1481,9 +1482,9 @@ export function PhotoViewerClient({
>
<div className="container mx-auto p-6 pointer-events-auto">
<h2 className="text-xl font-semibold mb-2">{currentPhoto.filename}</h2>
{currentPhoto.dateTaken && (
{currentPhoto.date_taken && (
<p className="text-sm text-gray-300 mb-2">
{new Date(currentPhoto.dateTaken).toLocaleDateString('en-US', {
{new Date(currentPhoto.date_taken).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
@ -1522,11 +1523,11 @@ export function PhotoViewerClient({
}}
faceId={clickedFace.faceId}
existingPerson={clickedFace.person ? {
firstName: clickedFace.person.firstName,
lastName: clickedFace.person.lastName,
middleName: clickedFace.person.middleName,
maidenName: clickedFace.person.maidenName,
dateOfBirth: clickedFace.person.dateOfBirth,
firstName: (clickedFace.person as any).first_name || (clickedFace.person as any).firstName,
lastName: (clickedFace.person as any).last_name || (clickedFace.person as any).lastName,
middleName: (clickedFace.person as any).middle_name || (clickedFace.person as any).middleName,
maidenName: (clickedFace.person as any).maiden_name || (clickedFace.person as any).maidenName,
dateOfBirth: (clickedFace.person as any).date_of_birth || (clickedFace.person as any).dateOfBirth,
} : null}
onSave={handleSaveFace}
/>

View File

@ -44,7 +44,7 @@ export function TagSelectionDialog({
return tags;
}
const query = searchQuery.toLowerCase();
return tags.filter((tag) => tag.tagName.toLowerCase().includes(query));
return tags.filter((tag) => tag.tag_name.toLowerCase().includes(query));
}, [searchQuery, tags]);
useEffect(() => {
@ -93,9 +93,9 @@ export function TagSelectionDialog({
setCustomTagInput('');
};
const removeCustomTag = (tagName: string) => {
const removeCustomTag = (tag_name: string) => {
setCustomTags((prev) =>
prev.filter((tag) => tag.toLowerCase() !== tagName.toLowerCase())
prev.filter((tag) => tag.toLowerCase() !== tag_name.toLowerCase())
);
};
@ -197,7 +197,7 @@ export function TagSelectionDialog({
checked={selectedTagIds.includes(tag.id)}
onCheckedChange={() => toggleTagSelection(tag.id)}
/>
<span className="text-sm">{tag.tagName}</span>
<span className="text-sm">{tag.tag_name}</span>
</label>
))
)}
@ -214,7 +214,7 @@ export function TagSelectionDialog({
className="flex items-center gap-1"
>
<TagIcon className="h-3 w-3" />
{tag.tagName}
{tag.tag_name}
</Badge>
);
})}

View File

@ -24,7 +24,7 @@ export function PeopleFilter({ people, selected, mode, onSelectionChange, onMode
const [open, setOpen] = useState(false);
const filteredPeople = people.filter((person) => {
const fullName = `${person.firstName} ${person.lastName}`.toLowerCase();
const fullName = `${person.first_name} ${person.last_name}`.toLowerCase();
return fullName.includes(searchQuery.toLowerCase());
});
@ -92,7 +92,7 @@ export function PeopleFilter({ people, selected, mode, onSelectionChange, onMode
/>
</span>
<label className="flex-1 cursor-pointer text-sm">
{person.firstName} {person.lastName}
{person.first_name} {person.last_name}
</label>
</div>
);
@ -111,7 +111,7 @@ export function PeopleFilter({ people, selected, mode, onSelectionChange, onMode
variant="secondary"
className="flex items-center gap-1"
>
{person.firstName} {person.lastName}
{person.first_name} {person.last_name}
<button
onClick={() => togglePerson(person.id)}
className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"

View File

@ -22,8 +22,14 @@ export function TagFilter({ tags, selected, mode, onSelectionChange, onModeChang
const [searchQuery, setSearchQuery] = useState('');
const [open, setOpen] = useState(false);
// Helper to safely get tag name, handling potential type mismatches
const getTagName = (tag: Tag | any): string => {
// Try multiple possible field names
return tag.tag_name || tag.tagName || tag.name || '';
};
const filteredTags = tags.filter((tag) => {
const tagName = tag.tagName || tag.tag_name || '';
const tagName = getTagName(tag);
return tagName.toLowerCase().includes(searchQuery.toLowerCase());
});
@ -91,7 +97,7 @@ export function TagFilter({ tags, selected, mode, onSelectionChange, onModeChang
/>
</span>
<label className="flex-1 cursor-pointer text-sm">
{tag.tagName || tag.tag_name || 'Unnamed Tag'}
{getTagName(tag) || 'Unnamed Tag'}
</label>
</div>
);
@ -110,7 +116,7 @@ export function TagFilter({ tags, selected, mode, onSelectionChange, onModeChang
variant="secondary"
className="flex items-center gap-1"
>
{tag.tagName || tag.tag_name || 'Unnamed Tag'}
{getTagName(tag) || 'Unnamed Tag'}
<button
onClick={() => toggleTag(tag.id)}
className="ml-1 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"

44
viewer-frontend/lib/db.ts Normal file
View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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
);
}

View File

@ -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();
}

View File

@ -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`;
}

View File

@ -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' },
});
}

View File

@ -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));
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "scripts"]
}