diff --git a/next.config.js b/next.config.js
index 8718f40..88cc54a 100644
--- a/next.config.js
+++ b/next.config.js
@@ -4,6 +4,12 @@ const nextConfig = {
formats: ['image/webp', 'image/avif'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
+ // Allow local patterns for our API
+ localPatterns: [
+ {
+ pathname: '/api/photos/**',
+ }
+ ]
},
}
diff --git a/package-lock.json b/package-lock.json
index 77f89b9..7541176 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,10 +12,12 @@
"@tabler/icons-react": "^3.34.1",
"@types/better-sqlite3": "^7.6.13",
"better-sqlite3": "^12.2.0",
+ "exif-reader": "^2.0.2",
"glob": "^11.0.3",
"next": "^15.5.0",
"react": "^19.1.1",
- "react-dom": "^19.1.1"
+ "react-dom": "^19.1.1",
+ "sharp": "^0.34.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.12",
@@ -1267,7 +1269,6 @@
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
- "optional": true,
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
@@ -1299,7 +1300,6 @@
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
- "optional": true,
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
@@ -1411,6 +1411,12 @@
"node": ">=6"
}
},
+ "node_modules/exif-reader": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/exif-reader/-/exif-reader-2.0.2.tgz",
+ "integrity": "sha512-CVFqcwdwFe2GnbbW7/Q7sUhVP5Ilaw7fuXQc6ad3AbX20uGfHhXTpkF/hQHPrtOuys9elFVgsUkvwfhfvjDa1A==",
+ "license": "MIT"
+ },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -1534,8 +1540,7 @@
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
- "license": "MIT",
- "optional": true
+ "license": "MIT"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
@@ -2260,7 +2265,6 @@
"integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==",
"hasInstallScript": true,
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.4",
@@ -2380,7 +2384,6 @@
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"license": "MIT",
- "optional": true,
"dependencies": {
"is-arrayish": "^0.3.1"
}
diff --git a/package.json b/package.json
index 37bf13d..587adbc 100644
--- a/package.json
+++ b/package.json
@@ -16,10 +16,12 @@
"@tabler/icons-react": "^3.34.1",
"@types/better-sqlite3": "^7.6.13",
"better-sqlite3": "^12.2.0",
+ "exif-reader": "^2.0.2",
"glob": "^11.0.3",
"next": "^15.5.0",
"react": "^19.1.1",
- "react-dom": "^19.1.1"
+ "react-dom": "^19.1.1",
+ "sharp": "^0.34.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.12",
diff --git a/src/app/api/duplicates/route.ts b/src/app/api/duplicates/route.ts
new file mode 100644
index 0000000..f989ed2
--- /dev/null
+++ b/src/app/api/duplicates/route.ts
@@ -0,0 +1,62 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { photoService } from '@/lib/photo-service'
+
+export async function GET(request: NextRequest) {
+ try {
+ const { searchParams } = new URL(request.url)
+ const type = searchParams.get('type') || 'hashes'
+
+ if (type === 'hashes') {
+ // Return duplicate hash records
+ const duplicateHashes = photoService.getDuplicateHashes()
+
+ return NextResponse.json({
+ success: true,
+ duplicates: duplicateHashes,
+ count: duplicateHashes.length
+ })
+
+ } else if (type === 'photos') {
+ // Return photos that have duplicates
+ const duplicatePhotos = photoService.getPhotosWithDuplicates()
+
+ return NextResponse.json({
+ success: true,
+ photos: duplicatePhotos,
+ count: duplicatePhotos.length
+ })
+
+ } else if (type === 'by-hash') {
+ // Return photos for a specific hash
+ const hash = searchParams.get('hash')
+ if (!hash) {
+ return NextResponse.json(
+ { error: 'Hash parameter is required for by-hash type' },
+ { status: 400 }
+ )
+ }
+
+ const photos = photoService.getPhotosByHash(hash)
+
+ return NextResponse.json({
+ success: true,
+ hash,
+ photos,
+ count: photos.length
+ })
+
+ } else {
+ return NextResponse.json(
+ { error: 'Invalid type parameter. Use: hashes, photos, or by-hash' },
+ { status: 400 }
+ )
+ }
+
+ } catch (error) {
+ console.error('Error fetching duplicates:', error)
+ return NextResponse.json(
+ { error: 'Failed to fetch duplicates' },
+ { status: 500 }
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/photos/[id]/thumbnail/route.ts b/src/app/api/photos/[id]/thumbnail/route.ts
new file mode 100644
index 0000000..18d5019
--- /dev/null
+++ b/src/app/api/photos/[id]/thumbnail/route.ts
@@ -0,0 +1,87 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { photoService } from '@/lib/photo-service'
+import sharp from 'sharp'
+import { existsSync } from 'fs'
+import { join } from 'path'
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params
+ const { searchParams } = new URL(request.url)
+
+ // Get size parameter (default: 200px)
+ const size = parseInt(searchParams.get('size') || '200')
+ const maxSize = Math.min(Math.max(size, 50), 800) // Clamp between 50-800px
+
+ const photo = photoService.getPhoto(id)
+
+ if (!photo) {
+ return NextResponse.json(
+ { error: 'Photo not found' },
+ { status: 404 }
+ )
+ }
+
+ // Check for cached thumbnail first
+ const cachedThumbnail = photoService.getCachedThumbnail(id, maxSize)
+ if (cachedThumbnail) {
+ // Return cached thumbnail immediately
+ return new NextResponse(cachedThumbnail, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'image/jpeg',
+ 'Cache-Control': 'public, max-age=31536000, immutable',
+ 'Content-Length': cachedThumbnail.length.toString(),
+ 'X-Cache': 'HIT', // Debug header
+ },
+ })
+ }
+
+ // Check if the file exists
+ if (!existsSync(photo.filepath)) {
+ return NextResponse.json(
+ { error: 'Photo file not found' },
+ { status: 404 }
+ )
+ }
+
+ // Generate thumbnail using Sharp
+ const thumbnail = await sharp(photo.filepath)
+ .resize(maxSize, maxSize, {
+ fit: 'inside',
+ withoutEnlargement: true,
+ background: { r: 240, g: 240, b: 240, alpha: 1 } // Light gray background
+ })
+ .jpeg({ quality: 85 })
+ .toBuffer()
+
+ // Asynchronously update the database cache (don't wait for it)
+ setImmediate(() => {
+ try {
+ photoService.updateThumbnailCache(id, maxSize, thumbnail)
+ } catch (error) {
+ console.error(`Failed to cache thumbnail for photo ${id}:`, error)
+ }
+ })
+
+ return new NextResponse(thumbnail as any, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'image/jpeg',
+ 'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year
+ 'Content-Length': thumbnail.length.toString(),
+ 'X-Cache': 'MISS', // Debug header
+ },
+ })
+
+ } catch (error) {
+ console.error('Error generating thumbnail:', error)
+ return NextResponse.json(
+ { error: 'Failed to generate thumbnail' },
+ { status: 500 }
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/scan/route.ts b/src/app/api/scan/route.ts
new file mode 100644
index 0000000..91e1645
--- /dev/null
+++ b/src/app/api/scan/route.ts
@@ -0,0 +1,84 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { photoService } from '@/lib/photo-service'
+import { scanDirectory } from '@/lib/file-scanner'
+
+export async function POST(request: NextRequest) {
+ const startTime = Date.now()
+ let directoryPath: string = ''
+
+ try {
+ const body = await request.json()
+ directoryPath = body.directoryPath
+
+ console.log(`[SCAN API] Received scan request for directory: ${directoryPath}`)
+
+ if (!directoryPath || typeof directoryPath !== 'string') {
+ console.error('[SCAN API] Invalid directory path provided:', directoryPath)
+ return NextResponse.json(
+ { error: 'Directory path is required' },
+ { status: 400 }
+ )
+ }
+
+ // Validate directory exists
+ const directoryRecord = photoService.getDirectoryByPath(directoryPath)
+ if (!directoryRecord) {
+ console.error(`[SCAN API] Directory not found in database: ${directoryPath}`)
+ return NextResponse.json(
+ { error: 'Directory not found in database' },
+ { status: 404 }
+ )
+ }
+
+ console.log(`[SCAN API] Starting background scan for: ${directoryPath}`)
+ console.log(`[SCAN API] Directory record:`, {
+ id: directoryRecord.id,
+ name: directoryRecord.name,
+ lastScanned: directoryRecord.last_scanned,
+ photoCount: directoryRecord.photo_count
+ })
+
+ // Update directory scan status immediately
+ photoService.createOrUpdateDirectory({
+ path: directoryPath,
+ name: directoryRecord.name,
+ last_scanned: new Date().toISOString(),
+ photo_count: directoryRecord.photo_count,
+ total_size: directoryRecord.total_size
+ })
+
+ console.log(`[SCAN API] Updated directory last_scanned timestamp`)
+
+ // Start the scanning process in the background
+ // Don't await this - let it run asynchronously
+ scanDirectory(directoryPath).then(result => {
+ const duration = Date.now() - startTime
+ console.log(`[SCAN API] Background scan completed for ${directoryPath}:`, {
+ duration: `${duration}ms`,
+ result
+ })
+ }).catch(error => {
+ const duration = Date.now() - startTime
+ console.error(`[SCAN API] Background scan failed for ${directoryPath} after ${duration}ms:`, error)
+ })
+
+ const responseTime = Date.now() - startTime
+ console.log(`[SCAN API] Responding immediately after ${responseTime}ms`)
+
+ // Return immediately
+ return NextResponse.json({
+ success: true,
+ message: 'Directory scan started in background',
+ directoryPath,
+ startTime: new Date().toISOString()
+ })
+
+ } catch (error) {
+ const responseTime = Date.now() - startTime
+ console.error(`[SCAN API] Error starting directory scan for ${directoryPath} after ${responseTime}ms:`, error)
+ return NextResponse.json(
+ { error: 'Failed to start directory scan' },
+ { status: 500 }
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/thumbnails/clear/route.ts b/src/app/api/thumbnails/clear/route.ts
new file mode 100644
index 0000000..649b43c
--- /dev/null
+++ b/src/app/api/thumbnails/clear/route.ts
@@ -0,0 +1,46 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { photoService } from '@/lib/photo-service'
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json()
+ const { photoId } = body
+
+ const cleared = photoService.clearThumbnailCache(photoId)
+
+ return NextResponse.json({
+ success: true,
+ cleared,
+ message: photoId
+ ? `Thumbnail cache cleared for photo ${photoId}`
+ : 'All thumbnail caches cleared'
+ })
+
+ } catch (error) {
+ console.error('Error clearing thumbnail cache:', error)
+ return NextResponse.json(
+ { error: 'Failed to clear thumbnail cache' },
+ { status: 500 }
+ )
+ }
+}
+
+export async function DELETE() {
+ try {
+ // Clear all thumbnail caches
+ const cleared = photoService.clearThumbnailCache()
+
+ return NextResponse.json({
+ success: true,
+ cleared,
+ message: 'All thumbnail caches cleared'
+ })
+
+ } catch (error) {
+ console.error('Error clearing all thumbnail caches:', error)
+ return NextResponse.json(
+ { error: 'Failed to clear thumbnail caches' },
+ { status: 500 }
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 01ac1ed..b05d863 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,3 +1,5 @@
+import PhotoGrid from "@/components/PhotoGrid";
+
export default function Home() {
return (
@@ -11,9 +13,7 @@ export default function Home() {
-
-
-
+
)
}
\ No newline at end of file
diff --git a/src/components/DirectoryList.tsx b/src/components/DirectoryList.tsx
index 5d71c43..b4cde2c 100644
--- a/src/components/DirectoryList.tsx
+++ b/src/components/DirectoryList.tsx
@@ -15,6 +15,7 @@ export default function DirectoryList({ onDirectorySelect, selectedDirectory, re
const [directories, setDirectories] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
+ const [scanningDirectories, setScanningDirectories] = useState>(new Set())
const fetchDirectories = async () => {
try {
@@ -53,6 +54,69 @@ export default function DirectoryList({ onDirectorySelect, selectedDirectory, re
}
}
+ const scanDirectory = async (directory: Directory) => {
+ const requestStartTime = Date.now()
+ console.log(`[CLIENT] Starting scan request for directory: ${directory.path}`)
+ console.log(`[CLIENT] Directory details:`, {
+ id: directory.id,
+ name: directory.name,
+ path: directory.path,
+ lastScanned: directory.last_scanned,
+ photoCount: directory.photo_count
+ })
+
+ try {
+ // Mark directory as scanning
+ setScanningDirectories(prev => new Set(prev).add(directory.path))
+ console.log(`[CLIENT] Marked directory as scanning: ${directory.path}`)
+
+ console.log(`[CLIENT] Sending POST request to /api/scan`)
+ const response = await fetch('/api/scan', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ directoryPath: directory.path }),
+ })
+
+ const requestDuration = Date.now() - requestStartTime
+ console.log(`[CLIENT] API response received after ${requestDuration}ms, status: ${response.status}`)
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ console.error(`[CLIENT] API request failed:`, errorText)
+ throw new Error(`Failed to start directory scan: ${response.status} ${errorText}`)
+ }
+
+ const result = await response.json()
+ console.log(`[CLIENT] Directory scan started successfully:`, result)
+ console.log(`[CLIENT] Background scan is now running for: ${directory.path}`)
+
+ // Remove scanning status after a brief delay (scan runs in background)
+ setTimeout(() => {
+ console.log(`[CLIENT] Removing scanning status for: ${directory.path}`)
+ setScanningDirectories(prev => {
+ const newSet = new Set(prev)
+ newSet.delete(directory.path)
+ return newSet
+ })
+ // Refresh directory list to show updated last_scanned time
+ console.log(`[CLIENT] Refreshing directory list to show updated scan time`)
+ fetchDirectories()
+ }, 2000)
+
+ } catch (error) {
+ const requestDuration = Date.now() - requestStartTime
+ console.error(`[CLIENT] Error starting directory scan for ${directory.path} after ${requestDuration}ms:`, error)
+ // Remove scanning status on error
+ setScanningDirectories(prev => {
+ const newSet = new Set(prev)
+ newSet.delete(directory.path)
+ return newSet
+ })
+ }
+ }
+
useEffect(() => {
fetchDirectories()
}, [refreshTrigger])
@@ -152,7 +216,16 @@ export default function DirectoryList({ onDirectorySelect, selectedDirectory, re
- Scan
+ {
+ e.stopPropagation()
+ scanDirectory(directory)
+ }}
+ disabled={scanningDirectories.has(directory.path)}
+ >
+ {scanningDirectories.has(directory.path) ? 'Scanning...' : 'Scan'}
+
diff --git a/src/components/DirectoryModal.tsx b/src/components/DirectoryModal.tsx
index b473467..6493d43 100644
--- a/src/components/DirectoryModal.tsx
+++ b/src/components/DirectoryModal.tsx
@@ -217,7 +217,7 @@ export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryLis
{suggestions.map((suggestion, index) => (
(suggestionRefs.current[index] = el)}
+ ref={(el) => { suggestionRefs.current[index] = el }}
onClick={() => handleSuggestionClick(suggestion)}
className={`px-3 py-2 cursor-pointer text-gray-900 dark:text-white border-b border-gray-200 dark:border-gray-600 last:border-b-0 ${
index === selectedSuggestionIndex
diff --git a/src/components/PhotoGrid.tsx b/src/components/PhotoGrid.tsx
new file mode 100644
index 0000000..c9c9950
--- /dev/null
+++ b/src/components/PhotoGrid.tsx
@@ -0,0 +1,240 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import PhotoThumbnail from './PhotoThumbnail'
+import { Photo } from '@/types/photo'
+import { IconPhoto, IconFilter, IconSearch, IconSortAscending, IconSortDescending } from '@tabler/icons-react'
+
+interface PhotoGridProps {
+ directoryPath?: string
+ showMetadata?: boolean
+ thumbnailSize?: 'small' | 'medium' | 'large'
+}
+
+type SortOption = 'created_at' | 'modified_at' | 'filename' | 'filesize'
+type SortOrder = 'ASC' | 'DESC'
+
+export default function PhotoGrid({
+ directoryPath,
+ showMetadata = true,
+ thumbnailSize = 'medium'
+}: PhotoGridProps) {
+ const [photos, setPhotos] = useState
([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [sortBy, setSortBy] = useState('created_at')
+ const [sortOrder, setSortOrder] = useState('DESC')
+ const [searchTerm, setSearchTerm] = useState('')
+ const [selectedPhoto, setSelectedPhoto] = useState(null)
+
+ const fetchPhotos = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+
+ const params = new URLSearchParams({
+ sortBy,
+ sortOrder,
+ limit: '100', // Load first 100 photos
+ })
+
+ if (directoryPath) {
+ params.append('directory', directoryPath)
+ }
+
+ const response = await fetch(`/api/photos?${params}`)
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch photos')
+ }
+
+ const data = await response.json()
+ setPhotos(data.photos || [])
+ } catch (error) {
+ console.error('Error fetching photos:', error)
+ setError('Failed to load photos')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ fetchPhotos()
+ }, [directoryPath, sortBy, sortOrder])
+
+ // Filter photos based on search term
+ const filteredPhotos = photos.filter(photo => {
+ if (!searchTerm) return true
+
+ const searchLower = searchTerm.toLowerCase()
+
+ // Search in filename
+ if (photo.filename.toLowerCase().includes(searchLower)) return true
+
+ // Search in metadata
+ try {
+ const metadata = photo.metadata ? JSON.parse(photo.metadata) : {}
+ const exif = metadata.exif || {}
+
+ // Search in camera info
+ if (exif.camera_make?.toLowerCase().includes(searchLower)) return true
+ if (exif.camera_model?.toLowerCase().includes(searchLower)) return true
+ if (exif.lens_model?.toLowerCase().includes(searchLower)) return true
+
+ } catch (error) {
+ // Ignore metadata parsing errors for search
+ }
+
+ return false
+ })
+
+ const handlePhotoClick = (photo: Photo) => {
+ setSelectedPhoto(photo)
+ }
+
+ const handleSortChange = (newSortBy: SortOption) => {
+ if (newSortBy === sortBy) {
+ // Toggle sort order if same field
+ setSortOrder(sortOrder === 'ASC' ? 'DESC' : 'ASC')
+ } else {
+ setSortBy(newSortBy)
+ setSortOrder('DESC') // Default to descending for new field
+ }
+ }
+
+ // Grid classes based on thumbnail size - made thumbnails larger
+ const gridClasses = {
+ small: 'grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 gap-2',
+ medium: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4',
+ large: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6'
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
Error Loading Photos
+
{error}
+
+ Try Again
+
+
+ )
+ }
+
+ if (photos.length === 0) {
+ return (
+
+
+
No Photos Found
+
+ {directoryPath
+ ? `No photos found in ${directoryPath}`
+ : 'No photos have been scanned yet. Select a directory and click scan to get started.'
+ }
+
+
+ )
+ }
+
+ return (
+
+ {/* Controls */}
+
+
+
+ Photos {directoryPath && `in ${directoryPath.split('/').pop()}`}
+
+
+ {filteredPhotos.length} photo{filteredPhotos.length !== 1 ? 's' : ''}
+
+
+
+
+ {/* Search */}
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ />
+
+
+ {/* Sort controls */}
+
+
+ handleSortChange(e.target.value as SortOption)}
+ className="border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm px-3 py-2 focus:ring-2 focus:ring-blue-500"
+ >
+ Date Created
+ Date Modified
+ Filename
+ File Size
+
+
+ setSortOrder(sortOrder === 'ASC' ? 'DESC' : 'ASC')}
+ className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
+ title={`Sort ${sortOrder === 'ASC' ? 'Descending' : 'Ascending'}`}
+ >
+ {sortOrder === 'ASC' ? : }
+
+
+
+
+
+ {/* Photo Grid */}
+
+ {filteredPhotos.map((photo) => (
+
+ ))}
+
+
+ {/* Photo Detail Modal (placeholder for future implementation) */}
+ {selectedPhoto && (
+
setSelectedPhoto(null)}
+ >
+
+
+ {selectedPhoto.filename}
+
+
+ Photo details modal - implement full photo viewer here
+
+
setSelectedPhoto(null)}
+ className="mt-4 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
+ >
+ Close
+
+
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/PhotoThumbnail.tsx b/src/components/PhotoThumbnail.tsx
new file mode 100644
index 0000000..3b92480
--- /dev/null
+++ b/src/components/PhotoThumbnail.tsx
@@ -0,0 +1,190 @@
+'use client'
+
+import { useState } from 'react'
+import { IconCamera, IconMapPin, IconCalendar, IconEye, IconHeart, IconStar } from '@tabler/icons-react'
+import { Photo } from '@/types/photo'
+
+interface PhotoThumbnailProps {
+ photo: Photo
+ size?: 'small' | 'medium' | 'large'
+ showMetadata?: boolean
+ onPhotoClick?: (photo: Photo) => void
+}
+
+export default function PhotoThumbnail({
+ photo,
+ size = 'medium',
+ showMetadata = false,
+ onPhotoClick
+}: PhotoThumbnailProps) {
+ const [imageError, setImageError] = useState(false)
+ const [showDetails, setShowDetails] = useState(false)
+
+ // Parse metadata
+ let metadata: any = {}
+ try {
+ metadata = photo.metadata ? JSON.parse(photo.metadata) : {}
+ } catch (error) {
+ console.warn('Failed to parse photo metadata:', error)
+ }
+
+ const exif = metadata.exif || {}
+
+ // Size configurations - keep square containers for grid layout
+ const sizeConfig = {
+ small: {
+ container: 'aspect-square',
+ thumbnail: 150
+ },
+ medium: {
+ container: 'aspect-square',
+ thumbnail: 200
+ },
+ large: {
+ container: 'aspect-square',
+ thumbnail: 300
+ }
+ }
+
+ const config = sizeConfig[size]
+
+ // Format metadata for display
+ const formatExposureTime = (time: number) => {
+ if (time >= 1) return `${time}s`
+ return `1/${Math.round(1 / time)}s`
+ }
+
+ const formatFocalLength = (length: number) => `${Math.round(length)}mm`
+
+ const formatISO = (iso: number | number[]) => {
+ const isoValue = Array.isArray(iso) ? iso[0] : iso
+ return `ISO ${isoValue}`
+ }
+
+ const formatDate = (dateString: string) => {
+ try {
+ return new Date(dateString).toLocaleDateString()
+ } catch {
+ return 'Unknown'
+ }
+ }
+
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return '0 B'
+ const k = 1024
+ const sizes = ['B', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+ }
+
+ return (
+ onPhotoClick?.(photo)}
+ onMouseEnter={() => setShowDetails(true)}
+ onMouseLeave={() => setShowDetails(false)}
+ >
+ {/* Thumbnail Image */}
+ {!imageError ? (
+
setImageError(true)}
+ loading="lazy"
+ />
+ ) : (
+
+
+
+ )}
+
+ {/* Favorite indicator */}
+ {photo.favorite && (
+
+
+
+ )}
+
+ {/* Rating indicator */}
+ {photo.rating && photo.rating > 0 && (
+
+ {[...Array(photo.rating)].map((_, i) => (
+
+ ))}
+
+ )}
+
+ {/* Metadata overlay - appears at bottom, leaving image visible */}
+ {showMetadata && (showDetails || size === 'large') && (
+
+
+ {/* Filename */}
+
+ {photo.filename}
+
+
+ {/* Camera info and settings in a compact row */}
+
+ {(exif.camera_make || exif.camera_model) && (
+
+
+
+ {[exif.camera_make, exif.camera_model].filter(Boolean).join(' ')}
+
+
+ )}
+
+ {/* Photo settings - compact display */}
+ {(exif.f_number || exif.exposure_time || exif.iso_speed) && (
+
+ {exif.f_number && f/{exif.f_number} }
+ {exif.exposure_time && {formatExposureTime(exif.exposure_time)} }
+ {exif.iso_speed && {formatISO(exif.iso_speed)} }
+
+ )}
+
+
+ {/* Second row: location, date, dimensions */}
+
+
+ {/* GPS location */}
+ {exif.gps && (exif.gps.latitude || exif.gps.longitude) && (
+
+
+ {exif.gps.latitude?.toFixed(2)}, {exif.gps.longitude?.toFixed(2)}
+
+ )}
+
+ {/* Date taken */}
+ {exif.date_time_original && (
+
+
+ {formatDate(exif.date_time_original)}
+
+ )}
+
+
+ {/* Image dimensions and file size */}
+
+ {photo.width && photo.height && (
+
{photo.width} × {photo.height}
+ )}
+
{formatFileSize(photo.filesize)}
+
+
+
+
+ )}
+
+ {/* Simple overlay for small sizes */}
+ {size === 'small' && showDetails && (
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/src/lib/database.ts b/src/lib/database.ts
index aba037f..140730e 100644
--- a/src/lib/database.ts
+++ b/src/lib/database.ts
@@ -46,6 +46,9 @@ function initializeTables(database: Database.Database) {
rating INTEGER CHECK (rating >= 0 AND rating <= 5),
description TEXT,
metadata TEXT, -- JSON string
+ thumbnail_blob BLOB, -- Cached thumbnail image data
+ thumbnail_size INTEGER, -- Size parameter used for cached thumbnail
+ thumbnail_generated_at DATETIME, -- When thumbnail was generated
indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
@@ -109,6 +112,28 @@ function initializeTables(database: Database.Database) {
)
`)
+ // Create image_hashes table for duplicate detection
+ database.exec(`
+ CREATE TABLE IF NOT EXISTS image_hashes (
+ id TEXT PRIMARY KEY,
+ sha256_hash TEXT NOT NULL UNIQUE,
+ first_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ file_count INTEGER DEFAULT 1
+ )
+ `)
+
+ // Create photo_hashes junction table to associate photos with their hashes
+ database.exec(`
+ CREATE TABLE IF NOT EXISTS photo_hashes (
+ photo_id TEXT NOT NULL,
+ hash_id TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (photo_id, hash_id),
+ FOREIGN KEY (photo_id) REFERENCES photos (id) ON DELETE CASCADE,
+ FOREIGN KEY (hash_id) REFERENCES image_hashes (id) ON DELETE CASCADE
+ )
+ `)
+
// Create indexes for better performance
database.exec(`
CREATE INDEX IF NOT EXISTS idx_photos_directory ON photos (directory);
@@ -122,6 +147,11 @@ function initializeTables(database: Database.Database) {
CREATE INDEX IF NOT EXISTS idx_photo_tags_photo_id ON photo_tags (photo_id);
CREATE INDEX IF NOT EXISTS idx_photo_tags_tag_id ON photo_tags (tag_id);
CREATE INDEX IF NOT EXISTS idx_directories_path ON directories (path);
+ CREATE INDEX IF NOT EXISTS idx_image_hashes_sha256 ON image_hashes (sha256_hash);
+ CREATE INDEX IF NOT EXISTS idx_photo_hashes_photo_id ON photo_hashes (photo_id);
+ CREATE INDEX IF NOT EXISTS idx_photo_hashes_hash_id ON photo_hashes (hash_id);
+ CREATE INDEX IF NOT EXISTS idx_photos_thumbnail_size ON photos (thumbnail_size);
+ CREATE INDEX IF NOT EXISTS idx_photos_thumbnail_generated_at ON photos (thumbnail_generated_at);
`)
}
diff --git a/src/lib/file-scanner.ts b/src/lib/file-scanner.ts
new file mode 100644
index 0000000..ae2dfe4
--- /dev/null
+++ b/src/lib/file-scanner.ts
@@ -0,0 +1,421 @@
+import { readdir, stat } from 'fs/promises'
+import { createReadStream } from 'fs'
+import { join, extname, basename } from 'path'
+import { photoService } from './photo-service'
+import { randomUUID, createHash } from 'crypto'
+import sharp from 'sharp'
+import exifReader from 'exif-reader'
+
+// Supported image file extensions
+const SUPPORTED_EXTENSIONS = new Set([
+ '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.svg'
+])
+
+interface ScanResult {
+ totalFiles: number
+ photosAdded: number
+ photosSkipped: number
+ errors: number
+}
+
+export async function scanDirectory(directoryPath: string): Promise {
+ const scanStartTime = Date.now()
+ console.log(`[FILE SCANNER] ========================================`)
+ console.log(`[FILE SCANNER] Starting scan of directory: ${directoryPath}`)
+ console.log(`[FILE SCANNER] Start time: ${new Date().toISOString()}`)
+
+ const result: ScanResult = {
+ totalFiles: 0,
+ photosAdded: 0,
+ photosSkipped: 0,
+ errors: 0
+ }
+
+ try {
+ console.log(`[FILE SCANNER] Beginning recursive directory scan...`)
+ await scanDirectoryRecursive(directoryPath, directoryPath, result)
+
+ const scanDuration = Date.now() - scanStartTime
+ console.log(`[FILE SCANNER] Recursive scan completed in ${scanDuration}ms`)
+ console.log(`[FILE SCANNER] Files processed: ${result.totalFiles}`)
+ console.log(`[FILE SCANNER] Photos added: ${result.photosAdded}`)
+ console.log(`[FILE SCANNER] Photos skipped: ${result.photosSkipped}`)
+ console.log(`[FILE SCANNER] Errors encountered: ${result.errors}`)
+
+ // Update directory statistics
+ console.log(`[FILE SCANNER] Updating directory statistics...`)
+ const directoryRecord = photoService.getDirectoryByPath(directoryPath)
+ if (directoryRecord) {
+ const directoryPhotos = photoService.getPhotos({ directory: directoryPath })
+ const totalSize = directoryPhotos.reduce((sum, photo) => sum + photo.filesize, 0)
+
+ console.log(`[FILE SCANNER] Directory stats: ${directoryPhotos.length} photos, ${totalSize} bytes`)
+
+ photoService.createOrUpdateDirectory({
+ path: directoryPath,
+ name: directoryRecord.name,
+ last_scanned: new Date().toISOString(),
+ photo_count: directoryPhotos.length,
+ total_size: totalSize
+ })
+
+ console.log(`[FILE SCANNER] Directory record updated successfully`)
+ } else {
+ console.warn(`[FILE SCANNER] Directory record not found for ${directoryPath}`)
+ }
+
+ const totalDuration = Date.now() - scanStartTime
+ console.log(`[FILE SCANNER] ========================================`)
+ console.log(`[FILE SCANNER] Scan completed for ${directoryPath}`)
+ console.log(`[FILE SCANNER] Total duration: ${totalDuration}ms`)
+ console.log(`[FILE SCANNER] Final result:`, result)
+ console.log(`[FILE SCANNER] ========================================`)
+
+ return result
+
+ } catch (error) {
+ const totalDuration = Date.now() - scanStartTime
+ console.error(`[FILE SCANNER] ========================================`)
+ console.error(`[FILE SCANNER] Error scanning directory ${directoryPath} after ${totalDuration}ms:`, error)
+ console.error(`[FILE SCANNER] Partial result:`, result)
+ console.error(`[FILE SCANNER] ========================================`)
+ result.errors++
+ return result
+ }
+}
+
+async function scanDirectoryRecursive(
+ currentPath: string,
+ basePath: string,
+ result: ScanResult
+): Promise {
+ try {
+ const entries = await readdir(currentPath, { withFileTypes: true })
+
+ for (const entry of entries) {
+ const fullPath = join(currentPath, entry.name)
+
+ try {
+ if (entry.isDirectory()) {
+ // Skip hidden directories and common non-photo directories
+ if (!entry.name.startsWith('.') &&
+ !['node_modules', 'dist', 'build', 'temp', 'cache'].includes(entry.name.toLowerCase())) {
+ await scanDirectoryRecursive(fullPath, basePath, result)
+ }
+ } else if (entry.isFile()) {
+ result.totalFiles++
+
+ const ext = extname(entry.name).toLowerCase()
+ if (SUPPORTED_EXTENSIONS.has(ext)) {
+ await processPhotoFile(fullPath, basePath, result)
+ }
+ }
+ } catch (fileError) {
+ console.error(`Error processing ${fullPath}:`, fileError)
+ result.errors++
+ }
+ }
+ } catch (error) {
+ console.error(`Error reading directory ${currentPath}:`, error)
+ result.errors++
+ }
+}
+
+async function processPhotoFile(
+ filePath: string,
+ basePath: string,
+ result: ScanResult
+): Promise {
+ const filename = basename(filePath)
+ let stats: any = null
+ let photoData: any = null
+
+ try {
+ // Check if photo already exists
+ const existingPhoto = photoService.getPhotoByPath(filePath)
+ if (existingPhoto) {
+ result.photosSkipped++
+ return
+ }
+
+ stats = await stat(filePath)
+
+ // Create basic photo record
+ photoData = {
+ filename,
+ filepath: filePath,
+ directory: basePath,
+ filesize: stats.size,
+ created_at: stats.birthtime.toISOString(),
+ modified_at: stats.mtime.toISOString(),
+ favorite: false,
+ metadata: JSON.stringify({
+ extension: extname(filename).toLowerCase(),
+ scanned_at: new Date().toISOString()
+ })
+ }
+
+ // Try to extract image metadata (width, height, format)
+ try {
+ const metadata = await extractImageMetadata(filePath)
+ Object.assign(photoData, metadata)
+ } catch (metadataError) {
+ console.warn(`[FILE SCANNER] Could not extract metadata for ${filePath}:`, metadataError)
+ }
+
+ console.log(`[FILE SCANNER] Creating photo record for: ${filename}`)
+ console.log(`[FILE SCANNER] Photo data:`, photoData)
+
+ // Debug: Log each value and its type before database insertion
+ console.log(`[FILE SCANNER] Debug - checking photoData types:`)
+ Object.entries(photoData).forEach(([key, value]) => {
+ console.log(` ${key}:`, typeof value, value)
+ })
+
+ // Create photo record
+ const photo = photoService.createPhoto(photoData)
+ result.photosAdded++
+ console.log(`[FILE SCANNER] Successfully added photo: ${filename}`)
+
+ // Compute and store SHA256 hash
+ try {
+ console.log(`[FILE SCANNER] Computing SHA256 hash for: ${filename}`)
+ const sha256Hash = await computeFileHash(filePath)
+ console.log(`[FILE SCANNER] Computed hash for ${filename}: ${sha256Hash}`)
+
+ // Create or update hash record
+ const hashRecord = photoService.createOrUpdateImageHash(sha256Hash)
+
+ // Associate photo with hash
+ const associated = photoService.associatePhotoWithHash(photo.id, hashRecord.id)
+ console.log(`[FILE SCANNER] Associated photo with hash: ${associated}`)
+
+ } catch (hashError) {
+ console.error(`[FILE SCANNER] Error computing hash for ${filePath}:`, hashError)
+ // Continue processing even if hash computation fails
+ }
+
+ // Log progress every 100 files
+ if ((result.photosAdded + result.photosSkipped) % 100 === 0) {
+ console.log(`[FILE SCANNER] Progress: ${result.photosAdded + result.photosSkipped} photos processed (${result.photosAdded} added, ${result.photosSkipped} skipped, ${result.errors} errors)`)
+ }
+
+ } catch (error) {
+ console.error(`[FILE SCANNER] Error processing photo ${filePath}:`, error)
+ console.error(`[FILE SCANNER] Photo data that failed:`, {
+ filename,
+ filepath: filePath,
+ directory: basePath,
+ filesize: stats ? stats.size : 'unknown',
+ photoData: photoData ? Object.keys(photoData) : 'not created'
+ })
+ result.errors++
+ }
+}
+
+async function extractImageMetadata(filePath: string): Promise<{
+ width?: number
+ height?: number
+ format?: string
+ metadata?: string
+}> {
+ try {
+ const ext = extname(filePath).toLowerCase()
+
+ // Skip SVG files as Sharp doesn't handle them well
+ if (ext === '.svg') {
+ return { format: 'SVG' }
+ }
+
+ // Use Sharp to get basic image information and EXIF data
+ const image = sharp(filePath)
+ const metadata = await image.metadata()
+
+ const result: {
+ width?: number
+ height?: number
+ format?: string
+ metadata?: string
+ } = {
+ width: typeof metadata.width === 'number' ? metadata.width : undefined,
+ height: typeof metadata.height === 'number' ? metadata.height : undefined,
+ format: metadata.format?.toUpperCase() || 'Unknown'
+ }
+
+ // Extract EXIF data if available
+ if (metadata.exif) {
+ try {
+ const exifData = exifReader(metadata.exif)
+
+ // Parse and store relevant EXIF information
+ const exifInfo: Record = {}
+
+ // Use any type to handle dynamic EXIF structure
+ const exif: any = exifData
+
+ // Image information
+ if (exif.image || exif.Image) {
+ const imageData = exif.image || exif.Image
+ if (imageData.Make) exifInfo.camera_make = imageData.Make
+ if (imageData.Model) exifInfo.camera_model = imageData.Model
+ if (imageData.Software) exifInfo.software = imageData.Software
+ if (imageData.DateTime) exifInfo.date_time = imageData.DateTime
+ if (imageData.Orientation) exifInfo.orientation = imageData.Orientation
+ if (imageData.XResolution) exifInfo.x_resolution = imageData.XResolution
+ if (imageData.YResolution) exifInfo.y_resolution = imageData.YResolution
+ }
+
+ // Photo-specific EXIF data
+ if (exif.exif || exif.Exif) {
+ const photoData = exif.exif || exif.Exif
+ if (photoData.DateTimeOriginal) exifInfo.date_time_original = photoData.DateTimeOriginal
+ if (photoData.DateTimeDigitized) exifInfo.date_time_digitized = photoData.DateTimeDigitized
+ if (photoData.ExposureTime) exifInfo.exposure_time = photoData.ExposureTime
+ if (photoData.FNumber) exifInfo.f_number = photoData.FNumber
+ if (photoData.ExposureProgram) exifInfo.exposure_program = photoData.ExposureProgram
+ if (photoData.ISOSpeedRatings) exifInfo.iso_speed = photoData.ISOSpeedRatings
+ if (photoData.FocalLength) exifInfo.focal_length = photoData.FocalLength
+ if (photoData.Flash) exifInfo.flash = photoData.Flash
+ if (photoData.WhiteBalance) exifInfo.white_balance = photoData.WhiteBalance
+ if (photoData.ColorSpace) exifInfo.color_space = photoData.ColorSpace
+ if (photoData.LensModel) exifInfo.lens_model = photoData.LensModel
+ }
+
+ // GPS information
+ if (exif.gps || exif.GPS) {
+ const gpsData = exif.gps || exif.GPS
+ const gpsInfo: Record = {}
+ if (gpsData.GPSLatitude && gpsData.GPSLatitudeRef) {
+ gpsInfo.latitude = convertDMSToDD(gpsData.GPSLatitude, gpsData.GPSLatitudeRef)
+ }
+ if (gpsData.GPSLongitude && gpsData.GPSLongitudeRef) {
+ gpsInfo.longitude = convertDMSToDD(gpsData.GPSLongitude, gpsData.GPSLongitudeRef)
+ }
+ if (gpsData.GPSAltitude && gpsData.GPSAltitudeRef !== undefined) {
+ gpsInfo.altitude = gpsData.GPSAltitudeRef === 1 ? -gpsData.GPSAltitude : gpsData.GPSAltitude
+ }
+ if (Object.keys(gpsInfo).length > 0) {
+ exifInfo.gps = gpsInfo
+ }
+ }
+
+ // Store EXIF data as JSON string if we found any relevant data
+ if (Object.keys(exifInfo).length > 0) {
+ result.metadata = JSON.stringify({
+ extension: ext,
+ scanned_at: new Date().toISOString(),
+ exif: exifInfo
+ })
+ }
+
+ } catch (exifError) {
+ console.warn(`Error parsing EXIF data for ${filePath}:`, exifError)
+ // Fall back to basic metadata
+ result.metadata = JSON.stringify({
+ extension: ext,
+ scanned_at: new Date().toISOString(),
+ exif_error: 'Failed to parse EXIF data'
+ })
+ }
+ } else {
+ // No EXIF data available
+ result.metadata = JSON.stringify({
+ extension: ext,
+ scanned_at: new Date().toISOString(),
+ exif: null
+ })
+ }
+
+ return result
+
+ } catch (error) {
+ console.warn(`Error extracting metadata for ${filePath}:`, error)
+
+ // Fall back to basic format detection
+ const ext = extname(filePath).toLowerCase()
+ const formatMap: Record = {
+ '.jpg': 'JPEG',
+ '.jpeg': 'JPEG',
+ '.png': 'PNG',
+ '.gif': 'GIF',
+ '.bmp': 'BMP',
+ '.webp': 'WebP',
+ '.tiff': 'TIFF',
+ '.tif': 'TIFF',
+ '.ico': 'ICO',
+ '.svg': 'SVG'
+ }
+
+ return {
+ format: formatMap[ext] || 'Unknown',
+ metadata: JSON.stringify({
+ extension: ext,
+ scanned_at: new Date().toISOString(),
+ extraction_error: error instanceof Error ? error.message : 'Unknown error'
+ })
+ }
+ }
+}
+
+// Helper function to convert DMS (Degrees, Minutes, Seconds) to DD (Decimal Degrees)
+function convertDMSToDD(dms: number[], ref: string): number {
+ if (!Array.isArray(dms) || dms.length < 3) return 0
+
+ const degrees = dms[0] || 0
+ const minutes = dms[1] || 0
+ const seconds = dms[2] || 0
+
+ let dd = degrees + minutes / 60 + seconds / 3600
+
+ if (ref === 'S' || ref === 'W') {
+ dd = -dd
+ }
+
+ return dd
+}
+
+// Helper function to compute SHA256 hash of a file
+async function computeFileHash(filePath: string): Promise {
+ return new Promise((resolve, reject) => {
+ const hash = createHash('sha256')
+ const stream = createReadStream(filePath)
+
+ stream.on('data', (data) => {
+ hash.update(data)
+ })
+
+ stream.on('end', () => {
+ resolve(hash.digest('hex'))
+ })
+
+ stream.on('error', (error) => {
+ reject(error)
+ })
+ })
+}
+
+// Helper function to check if a path contains photos
+export async function hasPhotos(directoryPath: string): Promise {
+ try {
+ const entries = await readdir(directoryPath, { withFileTypes: true })
+
+ for (const entry of entries) {
+ if (entry.isFile()) {
+ const ext = extname(entry.name).toLowerCase()
+ if (SUPPORTED_EXTENSIONS.has(ext)) {
+ return true
+ }
+ } else if (entry.isDirectory() && !entry.name.startsWith('.')) {
+ const fullPath = join(directoryPath, entry.name)
+ if (await hasPhotos(fullPath)) {
+ return true
+ }
+ }
+ }
+
+ return false
+ } catch (error) {
+ console.error(`Error checking for photos in ${directoryPath}:`, error)
+ return false
+ }
+}
\ No newline at end of file
diff --git a/src/lib/photo-service.ts b/src/lib/photo-service.ts
index cf34b45..3fae914 100644
--- a/src/lib/photo-service.ts
+++ b/src/lib/photo-service.ts
@@ -1,12 +1,12 @@
import { getDatabase } from './database'
-import { Photo, Album, Tag, Directory } from '@/types/photo'
+import { Photo, Album, Tag, Directory, ImageHash, PhotoHash } from '@/types/photo'
import { randomUUID } from 'crypto'
export class PhotoService {
private db = getDatabase()
// Photo operations
- createPhoto(photoData: Omit): Photo {
+ createPhoto(photoData: Omit): Photo {
const id = randomUUID()
const insertPhoto = this.db.prepare(`
INSERT INTO photos (
@@ -15,24 +15,69 @@ export class PhotoService {
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
- insertPhoto.run(
- id,
- photoData.filename,
- photoData.filepath,
- photoData.directory,
- photoData.filesize,
- photoData.created_at,
- photoData.modified_at,
- photoData.width,
- photoData.height,
- photoData.format,
- photoData.favorite,
- photoData.rating,
- photoData.description,
- photoData.metadata
- )
+ try {
+ // Sanitize each value to ensure it's a primitive type for SQLite
+ const sanitizeValue = (value: any): string | number | boolean | null => {
+ // Explicitly handle null and undefined
+ if (value === null || value === undefined) return null
+ // Handle primitive types
+ if (typeof value === 'string') return value
+ if (typeof value === 'number') return value
+ if (typeof value === 'boolean') return value
+ // Handle other types
+ if (typeof value === 'bigint') return Number(value)
+ if (value instanceof Date) return value.toISOString()
+ if (typeof value === 'object') {
+ // If it's null, return null (redundant but explicit)
+ if (value === null) return null
+ // Otherwise stringify the object
+ return JSON.stringify(value)
+ }
+ // Convert everything else to string
+ return String(value)
+ }
- return this.getPhoto(id)!
+ // Direct approach - build parameters with explicit type checking
+ const params = [
+ id, // string
+ photoData.filename, // string
+ photoData.filepath, // string
+ photoData.directory, // string
+ photoData.filesize, // number
+ photoData.created_at, // string
+ photoData.modified_at, // string
+ photoData.width || null, // number or null
+ photoData.height || null, // number or null
+ photoData.format || null, // string or null
+ photoData.favorite === true ? 1 : 0, // convert boolean to integer
+ null, // rating - always null for now
+ null, // description - always null for now
+ photoData.metadata || null // string or null
+ ]
+
+ // Debug: Log each parameter type before database insertion
+ console.log(`[PHOTO SERVICE] Debug - checking SQL parameters for ${photoData.filename}:`)
+ const fieldNames = ['id', 'filename', 'filepath', 'directory', 'filesize', 'created_at', 'modified_at', 'width', 'height', 'format', 'favorite', 'rating', 'description', 'metadata']
+ params.forEach((value, index) => {
+ const valueStr = value === null ? 'NULL' : (typeof value === 'string' && value.length > 50 ? value.substring(0, 50) + '...' : value)
+ console.log(` ${fieldNames[index]}:`, typeof value, valueStr)
+
+ // Additional check for SQLite compatibility
+ const isValidSQLiteValue = value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint'
+ if (!isValidSQLiteValue) {
+ console.error(` ❌ INVALID SQLite value for ${fieldNames[index]}:`, value, typeof value)
+ }
+ })
+
+ // Use spread operator with the clean params array
+ insertPhoto.run(...params)
+
+ return this.getPhoto(id)!
+ } catch (error) {
+ console.error(`[PHOTO SERVICE] Error creating photo ${photoData.filename}:`, error)
+ console.error(`[PHOTO SERVICE] Photo data:`, photoData)
+ throw error
+ }
}
getPhoto(id: string): Photo | null {
@@ -272,6 +317,115 @@ export class PhotoService {
const totalSize = this.db.prepare('SELECT SUM(filesize) as total FROM photos')
return (totalSize.get() as { total: number }).total || 0
}
+
+ // Hash operations
+ createOrUpdateImageHash(sha256Hash: string): ImageHash {
+ const existingHash = this.getImageHashBySha256(sha256Hash)
+
+ if (existingHash) {
+ // Update file count
+ const updateHash = this.db.prepare(`
+ UPDATE image_hashes SET file_count = file_count + 1 WHERE sha256_hash = ?
+ `)
+ updateHash.run(sha256Hash)
+ return this.getImageHashBySha256(sha256Hash)!
+ } else {
+ // Create new hash record
+ const id = randomUUID()
+ const insertHash = this.db.prepare(`
+ INSERT INTO image_hashes (id, sha256_hash, file_count) VALUES (?, ?, 1)
+ `)
+ insertHash.run(id, sha256Hash)
+ return this.getImageHash(id)!
+ }
+ }
+
+ getImageHash(id: string): ImageHash | null {
+ const selectHash = this.db.prepare('SELECT * FROM image_hashes WHERE id = ?')
+ return selectHash.get(id) as ImageHash | null
+ }
+
+ getImageHashBySha256(sha256Hash: string): ImageHash | null {
+ const selectHash = this.db.prepare('SELECT * FROM image_hashes WHERE sha256_hash = ?')
+ return selectHash.get(sha256Hash) as ImageHash | null
+ }
+
+ associatePhotoWithHash(photoId: string, hashId: string): boolean {
+ const insertPhotoHash = this.db.prepare(`
+ INSERT OR IGNORE INTO photo_hashes (photo_id, hash_id) VALUES (?, ?)
+ `)
+ const result = insertPhotoHash.run(photoId, hashId)
+ return result.changes > 0
+ }
+
+ getPhotosByHash(sha256Hash: string): Photo[] {
+ const selectPhotos = this.db.prepare(`
+ SELECT p.* FROM photos p
+ JOIN photo_hashes ph ON p.id = ph.photo_id
+ JOIN image_hashes ih ON ph.hash_id = ih.id
+ WHERE ih.sha256_hash = ?
+ ORDER BY p.created_at DESC
+ `)
+ return selectPhotos.all(sha256Hash) as Photo[]
+ }
+
+ getDuplicateHashes(): ImageHash[] {
+ const selectDuplicates = this.db.prepare(`
+ SELECT * FROM image_hashes WHERE file_count > 1 ORDER BY file_count DESC
+ `)
+ return selectDuplicates.all() as ImageHash[]
+ }
+
+ getPhotosWithDuplicates(): Photo[] {
+ const selectPhotos = this.db.prepare(`
+ SELECT DISTINCT p.* FROM photos p
+ JOIN photo_hashes ph ON p.id = ph.photo_id
+ JOIN image_hashes ih ON ph.hash_id = ih.id
+ WHERE ih.file_count > 1
+ ORDER BY p.created_at DESC
+ `)
+ return selectPhotos.all() as Photo[]
+ }
+
+ // Thumbnail caching operations
+ getCachedThumbnail(photoId: string, size: number): Buffer | null {
+ const selectThumbnail = this.db.prepare(`
+ SELECT thumbnail_blob FROM photos
+ WHERE id = ? AND thumbnail_size = ? AND thumbnail_blob IS NOT NULL
+ `)
+ const result = selectThumbnail.get(photoId, size) as { thumbnail_blob: Buffer } | null
+ return result?.thumbnail_blob || null
+ }
+
+ updateThumbnailCache(photoId: string, size: number, thumbnailData: Buffer): boolean {
+ const updateThumbnail = this.db.prepare(`
+ UPDATE photos
+ SET thumbnail_blob = ?, thumbnail_size = ?, thumbnail_generated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ `)
+ const result = updateThumbnail.run(thumbnailData, size, photoId)
+ return result.changes > 0
+ }
+
+ clearThumbnailCache(photoId?: string): boolean {
+ if (photoId) {
+ const clearThumbnail = this.db.prepare(`
+ UPDATE photos
+ SET thumbnail_blob = NULL, thumbnail_size = NULL, thumbnail_generated_at = NULL
+ WHERE id = ?
+ `)
+ const result = clearThumbnail.run(photoId)
+ return result.changes > 0
+ } else {
+ // Clear all thumbnail caches
+ const clearAllThumbnails = this.db.prepare(`
+ UPDATE photos
+ SET thumbnail_blob = NULL, thumbnail_size = NULL, thumbnail_generated_at = NULL
+ `)
+ const result = clearAllThumbnails.run()
+ return result.changes > 0
+ }
+ }
}
export const photoService = new PhotoService()
\ No newline at end of file
diff --git a/src/types/photo.ts b/src/types/photo.ts
index 4f14240..f696cfc 100644
--- a/src/types/photo.ts
+++ b/src/types/photo.ts
@@ -13,6 +13,9 @@ export interface Photo {
rating?: number
description?: string
metadata?: string // JSON string
+ thumbnail_blob?: Buffer // Cached thumbnail image data
+ thumbnail_size?: number // Size parameter used for cached thumbnail
+ thumbnail_generated_at?: string // When thumbnail was generated
indexed_at: string
}
@@ -51,4 +54,17 @@ export interface PhotoTag {
photo_id: string
tag_id: string
added_at: string
+}
+
+export interface ImageHash {
+ id: string
+ sha256_hash: string
+ first_seen_at: string
+ file_count: number
+}
+
+export interface PhotoHash {
+ photo_id: string
+ hash_id: string
+ created_at: string
}
\ No newline at end of file