photos/src/lib/photo-service.ts
Michael Mainguy 868ef2eeaa 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>
2025-08-27 08:35:07 -05:00

431 lines
14 KiB
TypeScript

import { getDatabase } from './database'
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, 'id' | 'indexed_at' | 'thumbnail_blob' | 'thumbnail_size' | 'thumbnail_generated_at'>): Photo {
const id = randomUUID()
const insertPhoto = this.db.prepare(`
INSERT INTO photos (
id, filename, filepath, directory, filesize, created_at, modified_at,
width, height, format, favorite, rating, description, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
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)
}
// 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 {
const selectPhoto = this.db.prepare('SELECT * FROM photos WHERE id = ?')
return selectPhoto.get(id) as Photo | null
}
getPhotoByPath(filepath: string): Photo | null {
const selectPhoto = this.db.prepare('SELECT * FROM photos WHERE filepath = ?')
return selectPhoto.get(filepath) as Photo | null
}
getPhotos(options: {
directory?: string
limit?: number
offset?: number
sortBy?: 'created_at' | 'modified_at' | 'filename' | 'filesize'
sortOrder?: 'ASC' | 'DESC'
favorite?: boolean
rating?: number
} = {}): Photo[] {
let query = 'SELECT * FROM photos WHERE 1=1'
const params: any[] = []
if (options.directory) {
query += ' AND directory = ?'
params.push(options.directory)
}
if (options.favorite !== undefined) {
query += ' AND favorite = ?'
params.push(options.favorite)
}
if (options.rating !== undefined) {
query += ' AND rating = ?'
params.push(options.rating)
}
const sortBy = options.sortBy || 'created_at'
const sortOrder = options.sortOrder || 'DESC'
query += ` ORDER BY ${sortBy} ${sortOrder}`
if (options.limit) {
query += ' LIMIT ?'
params.push(options.limit)
}
if (options.offset) {
query += ' OFFSET ?'
params.push(options.offset)
}
const selectPhotos = this.db.prepare(query)
return selectPhotos.all(...params) as Photo[]
}
updatePhoto(id: string, updates: Partial<Omit<Photo, 'id' | 'filepath' | 'indexed_at'>>): Photo | null {
const fields = Object.keys(updates).filter(key => updates[key as keyof typeof updates] !== undefined)
if (fields.length === 0) return this.getPhoto(id)
const setClause = fields.map(field => `${field} = ?`).join(', ')
const values = fields.map(field => updates[field as keyof typeof updates])
const updatePhoto = this.db.prepare(`UPDATE photos SET ${setClause} WHERE id = ?`)
updatePhoto.run(...values, id)
return this.getPhoto(id)
}
deletePhoto(id: string): boolean {
const deletePhoto = this.db.prepare('DELETE FROM photos WHERE id = ?')
const result = deletePhoto.run(id)
return result.changes > 0
}
// Album operations
createAlbum(albumData: Omit<Album, 'id' | 'created_at' | 'modified_at'>): Album {
const id = randomUUID()
const insertAlbum = this.db.prepare(`
INSERT INTO albums (id, name, description, cover_photo_id)
VALUES (?, ?, ?, ?)
`)
insertAlbum.run(id, albumData.name, albumData.description, albumData.cover_photo_id)
return this.getAlbum(id)!
}
getAlbum(id: string): Album | null {
const selectAlbum = this.db.prepare('SELECT * FROM albums WHERE id = ?')
return selectAlbum.get(id) as Album | null
}
getAlbums(): Album[] {
const selectAlbums = this.db.prepare('SELECT * FROM albums ORDER BY name')
return selectAlbums.all() as Album[]
}
addPhotoToAlbum(photoId: string, albumId: string): boolean {
const insertPhotoAlbum = this.db.prepare(`
INSERT OR IGNORE INTO photo_albums (photo_id, album_id)
VALUES (?, ?)
`)
const result = insertPhotoAlbum.run(photoId, albumId)
return result.changes > 0
}
removePhotoFromAlbum(photoId: string, albumId: string): boolean {
const deletePhotoAlbum = this.db.prepare(`
DELETE FROM photo_albums WHERE photo_id = ? AND album_id = ?
`)
const result = deletePhotoAlbum.run(photoId, albumId)
return result.changes > 0
}
getAlbumPhotos(albumId: string): Photo[] {
const selectAlbumPhotos = this.db.prepare(`
SELECT p.* FROM photos p
JOIN photo_albums pa ON p.id = pa.photo_id
WHERE pa.album_id = ?
ORDER BY pa.added_at DESC
`)
return selectAlbumPhotos.all(albumId) as Photo[]
}
// Tag operations
createTag(tagData: Omit<Tag, 'id' | 'created_at'>): Tag {
const id = randomUUID()
const insertTag = this.db.prepare(`
INSERT INTO tags (id, name, color) VALUES (?, ?, ?)
`)
insertTag.run(id, tagData.name, tagData.color)
return this.getTag(id)!
}
getTag(id: string): Tag | null {
const selectTag = this.db.prepare('SELECT * FROM tags WHERE id = ?')
return selectTag.get(id) as Tag | null
}
getTags(): Tag[] {
const selectTags = this.db.prepare('SELECT * FROM tags ORDER BY name')
return selectTags.all() as Tag[]
}
addPhotoTag(photoId: string, tagId: string): boolean {
const insertPhotoTag = this.db.prepare(`
INSERT OR IGNORE INTO photo_tags (photo_id, tag_id)
VALUES (?, ?)
`)
const result = insertPhotoTag.run(photoId, tagId)
return result.changes > 0
}
removePhotoTag(photoId: string, tagId: string): boolean {
const deletePhotoTag = this.db.prepare(`
DELETE FROM photo_tags WHERE photo_id = ? AND tag_id = ?
`)
const result = deletePhotoTag.run(photoId, tagId)
return result.changes > 0
}
getPhotoTags(photoId: string): Tag[] {
const selectPhotoTags = this.db.prepare(`
SELECT t.* FROM tags t
JOIN photo_tags pt ON t.id = pt.tag_id
WHERE pt.photo_id = ?
ORDER BY t.name
`)
return selectPhotoTags.all(photoId) as Tag[]
}
// Directory operations
createOrUpdateDirectory(directoryData: Omit<Directory, 'id'>): Directory {
const existingDir = this.getDirectoryByPath(directoryData.path)
if (existingDir) {
const updateDir = this.db.prepare(`
UPDATE directories SET name = ?, last_scanned = ?, photo_count = ?, total_size = ?
WHERE path = ?
`)
updateDir.run(
directoryData.name,
directoryData.last_scanned,
directoryData.photo_count,
directoryData.total_size,
directoryData.path
)
return this.getDirectoryByPath(directoryData.path)!
} else {
const id = randomUUID()
const insertDir = this.db.prepare(`
INSERT INTO directories (id, path, name, last_scanned, photo_count, total_size)
VALUES (?, ?, ?, ?, ?, ?)
`)
insertDir.run(
id,
directoryData.path,
directoryData.name,
directoryData.last_scanned,
directoryData.photo_count,
directoryData.total_size
)
return this.getDirectory(id)!
}
}
getDirectory(id: string): Directory | null {
const selectDir = this.db.prepare('SELECT * FROM directories WHERE id = ?')
return selectDir.get(id) as Directory | null
}
getDirectoryByPath(path: string): Directory | null {
const selectDir = this.db.prepare('SELECT * FROM directories WHERE path = ?')
return selectDir.get(path) as Directory | null
}
getDirectories(): Directory[] {
const selectDirs = this.db.prepare('SELECT * FROM directories ORDER BY last_scanned DESC')
return selectDirs.all() as Directory[]
}
deleteDirectory(id: string): boolean {
const deleteDir = this.db.prepare('DELETE FROM directories WHERE id = ?')
const result = deleteDir.run(id)
return result.changes > 0
}
// Statistics
getPhotoCount(): number {
const countPhotos = this.db.prepare('SELECT COUNT(*) as count FROM photos')
return (countPhotos.get() as { count: number }).count
}
getTotalFileSize(): number {
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()