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
- +
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 ( +
+
+

Loading photos...

+
+ ) + } + + if (error) { + return ( +
+ +

Error Loading Photos

+

{error}

+ +
+ ) + } + + 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 */} +
+ + + + +
+
+
+ + {/* 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 +

+ +
+
+ )} +
+ ) +} \ 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 ? ( + {photo.filename} 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 && ( +
+
+ {photo.filename} +
+
+ )} +
+ ) +} \ 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