Add photo scanning with EXIF metadata extraction and thumbnail caching
- Implement file scanner with SHA256 hash-based duplicate detection - Add Sharp-based thumbnail generation with object-contain display - Create comprehensive photo grid with EXIF metadata overlay - Add SQLite thumbnail blob caching for improved performance - Support full image preview with proper aspect ratio preservation - Include background directory scanning with progress tracking 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2d81844c05
commit
868ef2eeaa
@ -4,6 +4,12 @@ const nextConfig = {
|
|||||||
formats: ['image/webp', 'image/avif'],
|
formats: ['image/webp', 'image/avif'],
|
||||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||||
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||||
|
// Allow local patterns for our API
|
||||||
|
localPatterns: [
|
||||||
|
{
|
||||||
|
pathname: '/api/photos/**',
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
17
package-lock.json
generated
17
package-lock.json
generated
@ -12,10 +12,12 @@
|
|||||||
"@tabler/icons-react": "^3.34.1",
|
"@tabler/icons-react": "^3.34.1",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
|
"exif-reader": "^2.0.2",
|
||||||
"glob": "^11.0.3",
|
"glob": "^11.0.3",
|
||||||
"next": "^15.5.0",
|
"next": "^15.5.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1"
|
"react-dom": "^19.1.1",
|
||||||
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.12",
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
@ -1267,7 +1269,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1",
|
"color-convert": "^2.0.1",
|
||||||
"color-string": "^1.9.0"
|
"color-string": "^1.9.0"
|
||||||
@ -1299,7 +1300,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "^1.0.0",
|
"color-name": "^1.0.0",
|
||||||
"simple-swizzle": "^0.2.2"
|
"simple-swizzle": "^0.2.2"
|
||||||
@ -1411,6 +1411,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/expand-template": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
@ -1534,8 +1540,7 @@
|
|||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
|
||||||
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
|
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/is-fullwidth-code-point": {
|
"node_modules/is-fullwidth-code-point": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
@ -2260,7 +2265,6 @@
|
|||||||
"integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==",
|
"integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
"detect-libc": "^2.0.4",
|
"detect-libc": "^2.0.4",
|
||||||
@ -2380,7 +2384,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
|
||||||
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
|
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-arrayish": "^0.3.1"
|
"is-arrayish": "^0.3.1"
|
||||||
}
|
}
|
||||||
|
@ -16,10 +16,12 @@
|
|||||||
"@tabler/icons-react": "^3.34.1",
|
"@tabler/icons-react": "^3.34.1",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
|
"exif-reader": "^2.0.2",
|
||||||
"glob": "^11.0.3",
|
"glob": "^11.0.3",
|
||||||
"next": "^15.5.0",
|
"next": "^15.5.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1"
|
"react-dom": "^19.1.1",
|
||||||
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.12",
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
|
62
src/app/api/duplicates/route.ts
Normal file
62
src/app/api/duplicates/route.ts
Normal file
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
87
src/app/api/photos/[id]/thumbnail/route.ts
Normal file
87
src/app/api/photos/[id]/thumbnail/route.ts
Normal file
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
84
src/app/api/scan/route.ts
Normal file
84
src/app/api/scan/route.ts
Normal file
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
46
src/app/api/thumbnails/clear/route.ts
Normal file
46
src/app/api/thumbnails/clear/route.ts
Normal file
@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +1,5 @@
|
|||||||
|
import PhotoGrid from "@/components/PhotoGrid";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -11,9 +13,7 @@ export default function Home() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
<PhotoGrid showMetadata={true} thumbnailSize="medium" />
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -15,6 +15,7 @@ export default function DirectoryList({ onDirectorySelect, selectedDirectory, re
|
|||||||
const [directories, setDirectories] = useState<Directory[]>([])
|
const [directories, setDirectories] = useState<Directory[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [scanningDirectories, setScanningDirectories] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const fetchDirectories = async () => {
|
const fetchDirectories = async () => {
|
||||||
try {
|
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(() => {
|
useEffect(() => {
|
||||||
fetchDirectories()
|
fetchDirectories()
|
||||||
}, [refreshTrigger])
|
}, [refreshTrigger])
|
||||||
@ -152,7 +216,16 @@ export default function DirectoryList({ onDirectorySelect, selectedDirectory, re
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<Button leftIcon={IconScan}>Scan</Button>
|
<Button
|
||||||
|
leftIcon={IconScan}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
scanDirectory(directory)
|
||||||
|
}}
|
||||||
|
disabled={scanningDirectories.has(directory.path)}
|
||||||
|
>
|
||||||
|
{scanningDirectories.has(directory.path) ? 'Scanning...' : 'Scan'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -217,7 +217,7 @@ export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryLis
|
|||||||
{suggestions.map((suggestion, index) => (
|
{suggestions.map((suggestion, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
ref={(el) => (suggestionRefs.current[index] = el)}
|
ref={(el) => { suggestionRefs.current[index] = el }}
|
||||||
onClick={() => handleSuggestionClick(suggestion)}
|
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 ${
|
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
|
index === selectedSuggestionIndex
|
||||||
|
240
src/components/PhotoGrid.tsx
Normal file
240
src/components/PhotoGrid.tsx
Normal file
@ -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<Photo[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [sortBy, setSortBy] = useState<SortOption>('created_at')
|
||||||
|
const [sortOrder, setSortOrder] = useState<SortOrder>('DESC')
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(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 (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent mb-4"></div>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">Loading photos...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-red-600 dark:text-red-400">
|
||||||
|
<IconPhoto className="w-12 h-12 mb-4 opacity-50" />
|
||||||
|
<p className="text-lg font-medium mb-2">Error Loading Photos</p>
|
||||||
|
<p className="text-sm opacity-75">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchPhotos}
|
||||||
|
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (photos.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
<IconPhoto className="w-16 h-16 mb-4 opacity-50" />
|
||||||
|
<p className="text-xl font-medium mb-2">No Photos Found</p>
|
||||||
|
<p className="text-sm opacity-75">
|
||||||
|
{directoryPath
|
||||||
|
? `No photos found in ${directoryPath}`
|
||||||
|
: 'No photos have been scanned yet. Select a directory and click scan to get started.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Photos {directoryPath && `in ${directoryPath.split('/').pop()}`}
|
||||||
|
</h2>
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{filteredPhotos.length} photo{filteredPhotos.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<IconSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search photos..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconFilter className="w-4 h-4 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
>
|
||||||
|
<option value="created_at">Date Created</option>
|
||||||
|
<option value="modified_at">Date Modified</option>
|
||||||
|
<option value="filename">Filename</option>
|
||||||
|
<option value="filesize">File Size</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => 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' ? <IconSortAscending className="w-4 h-4" /> : <IconSortDescending className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Grid */}
|
||||||
|
<div className={`grid ${gridClasses[thumbnailSize]}`}>
|
||||||
|
{filteredPhotos.map((photo) => (
|
||||||
|
<PhotoThumbnail
|
||||||
|
key={photo.id}
|
||||||
|
photo={photo}
|
||||||
|
size={thumbnailSize}
|
||||||
|
showMetadata={showMetadata}
|
||||||
|
onPhotoClick={handlePhotoClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Photo Detail Modal (placeholder for future implementation) */}
|
||||||
|
{selectedPhoto && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4"
|
||||||
|
onClick={() => setSelectedPhoto(null)}
|
||||||
|
>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-2xl w-full max-h-screen overflow-y-auto">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||||
|
{selectedPhoto.filename}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Photo details modal - implement full photo viewer here
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => 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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
190
src/components/PhotoThumbnail.tsx
Normal file
190
src/components/PhotoThumbnail.tsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
className={`relative group cursor-pointer overflow-hidden rounded-lg shadow-md hover:shadow-lg transition-all duration-200 bg-gray-100 dark:bg-gray-800 ${config.container} w-full`}
|
||||||
|
onClick={() => onPhotoClick?.(photo)}
|
||||||
|
onMouseEnter={() => setShowDetails(true)}
|
||||||
|
onMouseLeave={() => setShowDetails(false)}
|
||||||
|
>
|
||||||
|
{/* Thumbnail Image */}
|
||||||
|
{!imageError ? (
|
||||||
|
<img
|
||||||
|
src={`/api/photos/${photo.id}/thumbnail?size=${config.thumbnail}`}
|
||||||
|
alt={photo.filename}
|
||||||
|
className="absolute inset-0 w-full h-full object-contain transition-transform duration-200 group-hover:scale-105"
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-600">
|
||||||
|
<IconCamera size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Favorite indicator */}
|
||||||
|
{photo.favorite && (
|
||||||
|
<div className="absolute top-2 right-2 z-10">
|
||||||
|
<IconHeart className="w-5 h-5 text-red-500 fill-current" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rating indicator */}
|
||||||
|
{photo.rating && photo.rating > 0 && (
|
||||||
|
<div className="absolute top-2 left-2 z-10 flex">
|
||||||
|
{[...Array(photo.rating)].map((_, i) => (
|
||||||
|
<IconStar key={i} className="w-4 h-4 text-yellow-400 fill-current" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata overlay - appears at bottom, leaving image visible */}
|
||||||
|
{showMetadata && (showDetails || size === 'large') && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black via-black/80 to-transparent text-white p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
{/* Filename */}
|
||||||
|
<div className="font-medium truncate" title={photo.filename}>
|
||||||
|
{photo.filename}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Camera info and settings in a compact row */}
|
||||||
|
<div className="flex items-center justify-between text-gray-300">
|
||||||
|
{(exif.camera_make || exif.camera_model) && (
|
||||||
|
<div className="flex items-center gap-1 truncate flex-1 mr-2">
|
||||||
|
<IconCamera className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{[exif.camera_make, exif.camera_model].filter(Boolean).join(' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photo settings - compact display */}
|
||||||
|
{(exif.f_number || exif.exposure_time || exif.iso_speed) && (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
{exif.f_number && <span>f/{exif.f_number}</span>}
|
||||||
|
{exif.exposure_time && <span>{formatExposureTime(exif.exposure_time)}</span>}
|
||||||
|
{exif.iso_speed && <span>{formatISO(exif.iso_speed)}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Second row: location, date, dimensions */}
|
||||||
|
<div className="flex items-center justify-between text-gray-400 text-xs">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* GPS location */}
|
||||||
|
{exif.gps && (exif.gps.latitude || exif.gps.longitude) && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<IconMapPin className="w-3 h-3" />
|
||||||
|
<span>{exif.gps.latitude?.toFixed(2)}, {exif.gps.longitude?.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date taken */}
|
||||||
|
{exif.date_time_original && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<IconCalendar className="w-3 h-3" />
|
||||||
|
<span>{formatDate(exif.date_time_original)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image dimensions and file size */}
|
||||||
|
<div className="text-right">
|
||||||
|
{photo.width && photo.height && (
|
||||||
|
<div>{photo.width} × {photo.height}</div>
|
||||||
|
)}
|
||||||
|
<div>{formatFileSize(photo.filesize)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Simple overlay for small sizes */}
|
||||||
|
{size === 'small' && showDetails && (
|
||||||
|
<div className="absolute inset-0 bg-black bg-opacity-50 text-white p-2 flex items-end opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
<div className="text-xs font-medium truncate" title={photo.filename}>
|
||||||
|
{photo.filename}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -46,6 +46,9 @@ function initializeTables(database: Database.Database) {
|
|||||||
rating INTEGER CHECK (rating >= 0 AND rating <= 5),
|
rating INTEGER CHECK (rating >= 0 AND rating <= 5),
|
||||||
description TEXT,
|
description TEXT,
|
||||||
metadata TEXT, -- JSON string
|
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
|
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
|
// Create indexes for better performance
|
||||||
database.exec(`
|
database.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_photos_directory ON photos (directory);
|
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_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_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_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);
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
421
src/lib/file-scanner.ts
Normal file
421
src/lib/file-scanner.ts
Normal file
@ -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<ScanResult> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, any> = {}
|
||||||
|
|
||||||
|
// 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<string, any> = {}
|
||||||
|
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<string, string> = {
|
||||||
|
'.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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,12 @@
|
|||||||
import { getDatabase } from './database'
|
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'
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
export class PhotoService {
|
export class PhotoService {
|
||||||
private db = getDatabase()
|
private db = getDatabase()
|
||||||
|
|
||||||
// Photo operations
|
// Photo operations
|
||||||
createPhoto(photoData: Omit<Photo, 'id' | 'indexed_at'>): Photo {
|
createPhoto(photoData: Omit<Photo, 'id' | 'indexed_at' | 'thumbnail_blob' | 'thumbnail_size' | 'thumbnail_generated_at'>): Photo {
|
||||||
const id = randomUUID()
|
const id = randomUUID()
|
||||||
const insertPhoto = this.db.prepare(`
|
const insertPhoto = this.db.prepare(`
|
||||||
INSERT INTO photos (
|
INSERT INTO photos (
|
||||||
@ -15,24 +15,69 @@ export class PhotoService {
|
|||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`)
|
`)
|
||||||
|
|
||||||
insertPhoto.run(
|
try {
|
||||||
id,
|
// Sanitize each value to ensure it's a primitive type for SQLite
|
||||||
photoData.filename,
|
const sanitizeValue = (value: any): string | number | boolean | null => {
|
||||||
photoData.filepath,
|
// Explicitly handle null and undefined
|
||||||
photoData.directory,
|
if (value === null || value === undefined) return null
|
||||||
photoData.filesize,
|
// Handle primitive types
|
||||||
photoData.created_at,
|
if (typeof value === 'string') return value
|
||||||
photoData.modified_at,
|
if (typeof value === 'number') return value
|
||||||
photoData.width,
|
if (typeof value === 'boolean') return value
|
||||||
photoData.height,
|
// Handle other types
|
||||||
photoData.format,
|
if (typeof value === 'bigint') return Number(value)
|
||||||
photoData.favorite,
|
if (value instanceof Date) return value.toISOString()
|
||||||
photoData.rating,
|
if (typeof value === 'object') {
|
||||||
photoData.description,
|
// If it's null, return null (redundant but explicit)
|
||||||
photoData.metadata
|
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 {
|
getPhoto(id: string): Photo | null {
|
||||||
@ -272,6 +317,115 @@ export class PhotoService {
|
|||||||
const totalSize = this.db.prepare('SELECT SUM(filesize) as total FROM photos')
|
const totalSize = this.db.prepare('SELECT SUM(filesize) as total FROM photos')
|
||||||
return (totalSize.get() as { total: number }).total || 0
|
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()
|
export const photoService = new PhotoService()
|
@ -13,6 +13,9 @@ export interface Photo {
|
|||||||
rating?: number
|
rating?: number
|
||||||
description?: string
|
description?: string
|
||||||
metadata?: string // JSON 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
|
indexed_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,3 +55,16 @@ export interface PhotoTag {
|
|||||||
tag_id: string
|
tag_id: string
|
||||||
added_at: 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
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user