- 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>
431 lines
14 KiB
TypeScript
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() |