Add duplicate detection, conflict handling, and fix pagination issues
- Add photo_conflicts table for files with same path but different content - Implement SHA256-based duplicate detection in file scanner - Add conflict detection methods to PhotoService - Skip identical files with info logging, store conflicts with warnings - Fix infinite scroll pagination race conditions with functional state updates - Add scroll throttling to prevent rapid API calls - Enhance PhotoThumbnail with comprehensive EXIF date/time display - Add composite React keys to prevent duplicate rendering issues 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
f6b651eeda
commit
5c3ad988f5
@ -15,11 +15,34 @@ export async function GET(request: NextRequest) {
|
|||||||
rating: searchParams.get('rating') ? parseInt(searchParams.get('rating')!) : undefined
|
rating: searchParams.get('rating') ? parseInt(searchParams.get('rating')!) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get photos with pagination
|
||||||
const photos = photoService.getPhotos(options)
|
const photos = photoService.getPhotos(options)
|
||||||
|
|
||||||
|
// Get total count for pagination metadata
|
||||||
|
const totalCountOptions = { ...options }
|
||||||
|
delete totalCountOptions.limit
|
||||||
|
delete totalCountOptions.offset
|
||||||
|
const totalPhotos = photoService.getPhotos(totalCountOptions)
|
||||||
|
const totalCount = totalPhotos.length
|
||||||
|
|
||||||
|
// Calculate pagination metadata
|
||||||
|
const limit = options.limit || 20
|
||||||
|
const offset = options.offset || 0
|
||||||
|
const hasMore = (offset + photos.length) < totalCount
|
||||||
|
const currentPage = Math.floor(offset / limit) + 1
|
||||||
|
const totalPages = Math.ceil(totalCount / limit)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
photos,
|
photos,
|
||||||
count: photos.length
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
count: photos.length,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore,
|
||||||
|
currentPage,
|
||||||
|
totalPages
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching photos:', error)
|
console.error('Error fetching photos:', error)
|
||||||
|
@ -180,15 +180,20 @@ export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryLis
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div className="absolute inset-0 bg-black bg-opacity-50 z-[9999]" style={{ display: 'block', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, height: '100vh', width: '100vw' }}>
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-[9999] flex items-center justify-center p-4">
|
||||||
<div
|
<div
|
||||||
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-800 p-6 rounded-lg w-96 shadow-2xl"
|
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
{/* Header */}
|
||||||
|
<div className="p-6 pb-4 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
Select Directory to Scan
|
Select Directory to Scan
|
||||||
</h2>
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area - Scrollable */}
|
||||||
|
<div className="flex-1 p-6 min-h-0">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -212,8 +217,9 @@ export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryLis
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions - Now with better scrolling */}
|
||||||
{showSuggestions && suggestions.length > 0 && (
|
{showSuggestions && suggestions.length > 0 && (
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-lg max-h-48 overflow-y-auto z-10">
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-lg max-h-64 overflow-y-auto z-10">
|
||||||
{suggestions.map((suggestion, index) => (
|
{suggestions.map((suggestion, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
@ -225,14 +231,25 @@ export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryLis
|
|||||||
: 'hover:bg-gray-100 dark:hover:bg-gray-600'
|
: 'hover:bg-gray-100 dark:hover:bg-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<div className="truncate" title={suggestion}>
|
||||||
{suggestion}
|
{suggestion}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 mt-4">
|
{/* Fixed Footer with Buttons */}
|
||||||
|
<div className="p-6 pt-4 border-t border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-750 rounded-b-lg">
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleClose}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!isValid || isValidating}
|
disabled={!isValid || isValidating}
|
||||||
@ -240,12 +257,7 @@ export default function DirectoryModal({ isOpen, onClose, onSave, onDirectoryLis
|
|||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</div>
|
||||||
onClick={handleClose}
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -22,21 +22,36 @@ export default function PhotoGrid({
|
|||||||
}: PhotoGridProps) {
|
}: PhotoGridProps) {
|
||||||
const [photos, setPhotos] = useState<Photo[]>([])
|
const [photos, setPhotos] = useState<Photo[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [sortBy, setSortBy] = useState<SortOption>('created_at')
|
const [sortBy, setSortBy] = useState<SortOption>('created_at')
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('DESC')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('DESC')
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null)
|
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null)
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
total: 0,
|
||||||
|
hasMore: false,
|
||||||
|
currentPage: 1,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0
|
||||||
|
})
|
||||||
|
|
||||||
const fetchPhotos = async () => {
|
const fetchPhotos = async (reset = true) => {
|
||||||
try {
|
try {
|
||||||
|
if (reset) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setPhotos([])
|
||||||
|
} else {
|
||||||
|
setLoadingMore(true)
|
||||||
|
}
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
|
const offset = reset ? 0 : pagination.offset
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
limit: '100', // Load first 100 photos
|
limit: pagination.limit.toString(),
|
||||||
|
offset: offset.toString()
|
||||||
})
|
})
|
||||||
|
|
||||||
if (directoryPath) {
|
if (directoryPath) {
|
||||||
@ -50,12 +65,42 @@ export default function PhotoGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
setPhotos(data.photos || [])
|
setPhotos(data.photos || [])
|
||||||
|
} else {
|
||||||
|
// Prevent duplicates by filtering out photos we already have using functional update
|
||||||
|
setPhotos(prev => {
|
||||||
|
const incomingPhotos = data.photos || []
|
||||||
|
const newPhotos = incomingPhotos.filter(newPhoto =>
|
||||||
|
!prev.some(existingPhoto => existingPhoto.id === newPhoto.id)
|
||||||
|
)
|
||||||
|
console.log(`[PHOTO GRID] Received ${incomingPhotos.length} photos, ${newPhotos.length} are new, ${incomingPhotos.length - newPhotos.length} were duplicates`)
|
||||||
|
return [...prev, ...newPhotos]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setPagination({
|
||||||
|
total: data.pagination.total,
|
||||||
|
hasMore: data.pagination.hasMore,
|
||||||
|
currentPage: data.pagination.currentPage,
|
||||||
|
limit: data.pagination.limit,
|
||||||
|
offset: offset + (data.photos || []).length
|
||||||
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching photos:', error)
|
console.error('Error fetching photos:', error)
|
||||||
setError('Failed to load photos')
|
setError('Failed to load photos')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMorePhotos = () => {
|
||||||
|
if (!loadingMore && pagination.hasMore) {
|
||||||
|
console.log(`[PHOTO GRID] Loading more photos: offset=${pagination.offset}, current photos=${photos.length}`)
|
||||||
|
fetchPhotos(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +108,31 @@ export default function PhotoGrid({
|
|||||||
fetchPhotos()
|
fetchPhotos()
|
||||||
}, [directoryPath, sortBy, sortOrder])
|
}, [directoryPath, sortBy, sortOrder])
|
||||||
|
|
||||||
|
// Infinite scroll effect
|
||||||
|
useEffect(() => {
|
||||||
|
let scrollTimeout: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (scrollTimeout) return // Prevent multiple calls
|
||||||
|
|
||||||
|
scrollTimeout = setTimeout(() => {
|
||||||
|
if (
|
||||||
|
window.innerHeight + document.documentElement.scrollTop + 1000 >=
|
||||||
|
document.documentElement.offsetHeight
|
||||||
|
) {
|
||||||
|
loadMorePhotos()
|
||||||
|
}
|
||||||
|
scrollTimeout = null
|
||||||
|
}, 100) // 100ms throttle
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll)
|
||||||
|
if (scrollTimeout) clearTimeout(scrollTimeout)
|
||||||
|
}
|
||||||
|
}, [loadingMore, pagination.hasMore])
|
||||||
|
|
||||||
// Filter photos based on search term
|
// Filter photos based on search term
|
||||||
const filteredPhotos = photos.filter(photo => {
|
const filteredPhotos = photos.filter(photo => {
|
||||||
if (!searchTerm) return true
|
if (!searchTerm) return true
|
||||||
@ -101,6 +171,8 @@ export default function PhotoGrid({
|
|||||||
setSortBy(newSortBy)
|
setSortBy(newSortBy)
|
||||||
setSortOrder('DESC') // Default to descending for new field
|
setSortOrder('DESC') // Default to descending for new field
|
||||||
}
|
}
|
||||||
|
// Reset pagination when sorting changes
|
||||||
|
setPagination(prev => ({ ...prev, currentPage: 1, hasMore: false, offset: 0 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grid classes based on thumbnail size - made thumbnails larger
|
// Grid classes based on thumbnail size - made thumbnails larger
|
||||||
@ -159,7 +231,8 @@ export default function PhotoGrid({
|
|||||||
Photos {directoryPath && `in ${directoryPath.split('/').pop()}`}
|
Photos {directoryPath && `in ${directoryPath.split('/').pop()}`}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{filteredPhotos.length} photo{filteredPhotos.length !== 1 ? 's' : ''}
|
{filteredPhotos.length} of {pagination.total} photo{pagination.total !== 1 ? 's' : ''}
|
||||||
|
{pagination.hasMore && <span className="ml-1">• Loading more...</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -205,7 +278,7 @@ export default function PhotoGrid({
|
|||||||
<div className={`grid ${gridClasses[thumbnailSize]}`}>
|
<div className={`grid ${gridClasses[thumbnailSize]}`}>
|
||||||
{filteredPhotos.map((photo) => (
|
{filteredPhotos.map((photo) => (
|
||||||
<PhotoThumbnail
|
<PhotoThumbnail
|
||||||
key={photo.id}
|
key={`${photo.id}-${photo.filepath}`}
|
||||||
photo={photo}
|
photo={photo}
|
||||||
size={thumbnailSize}
|
size={thumbnailSize}
|
||||||
showMetadata={showMetadata}
|
showMetadata={showMetadata}
|
||||||
@ -214,6 +287,33 @@ export default function PhotoGrid({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Load More Button/Indicator */}
|
||||||
|
{pagination.hasMore && !loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
{loadingMore ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-4 border-blue-600 border-t-transparent mb-2"></div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">Loading more photos...</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={loadMorePhotos}
|
||||||
|
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Load More Photos
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End of Results */}
|
||||||
|
{!loading && !loadingMore && !pagination.hasMore && photos.length > 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<p className="text-sm">You've reached the end of your photos</p>
|
||||||
|
<p className="text-xs mt-1">Showing all {pagination.total} photos</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Full Resolution Image Modal */}
|
{/* Full Resolution Image Modal */}
|
||||||
<ImageModal
|
<ImageModal
|
||||||
photo={selectedPhoto}
|
photo={selectedPhoto}
|
||||||
|
@ -69,6 +69,29 @@ export default function PhotoThumbnail({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
return new Date(dateString).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return 'Unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the best available date from EXIF data
|
||||||
|
const getBestDate = () => {
|
||||||
|
if (exif.date_time_original) return exif.date_time_original
|
||||||
|
if (exif.date_time_digitized) return exif.date_time_digitized
|
||||||
|
if (exif.date_time) return exif.date_time
|
||||||
|
if (photo.created_at) return photo.created_at
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
const formatFileSize = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B'
|
if (bytes === 0) return '0 B'
|
||||||
const k = 1024
|
const k = 1024
|
||||||
@ -156,11 +179,13 @@ export default function PhotoThumbnail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Date taken */}
|
{/* Date taken with time */}
|
||||||
{exif.date_time_original && (
|
{getBestDate() && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<IconCalendar className="w-3 h-3" />
|
<IconCalendar className="w-3 h-3" />
|
||||||
<span>{formatDate(exif.date_time_original)}</span>
|
<span title={`Original: ${exif.date_time_original || 'N/A'}\nDigitized: ${exif.date_time_digitized || 'N/A'}\nModified: ${exif.date_time || 'N/A'}`}>
|
||||||
|
{formatDateTime(getBestDate()!)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -173,6 +198,21 @@ export default function PhotoThumbnail({
|
|||||||
<div>{formatFileSize(photo.filesize)}</div>
|
<div>{formatFileSize(photo.filesize)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Third row: Additional EXIF date info for large thumbnails */}
|
||||||
|
{size === 'large' && (exif.date_time_original || exif.date_time_digitized || exif.date_time) && (
|
||||||
|
<div className="text-gray-500 text-xs mt-1 space-y-0.5">
|
||||||
|
{exif.date_time_original && exif.date_time_original !== getBestDate() && (
|
||||||
|
<div>📷 Taken: {formatDateTime(exif.date_time_original)}</div>
|
||||||
|
)}
|
||||||
|
{exif.date_time_digitized && exif.date_time_digitized !== getBestDate() && (
|
||||||
|
<div>💾 Digitized: {formatDateTime(exif.date_time_digitized)}</div>
|
||||||
|
)}
|
||||||
|
{exif.date_time && exif.date_time !== getBestDate() && (
|
||||||
|
<div>📝 Modified: {formatDateTime(exif.date_time)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -134,6 +134,33 @@ function initializeTables(database: Database.Database) {
|
|||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
// Create photo_conflicts table for files with same path but different content
|
||||||
|
database.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS photo_conflicts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
filepath TEXT NOT NULL,
|
||||||
|
directory TEXT NOT NULL,
|
||||||
|
filesize INTEGER NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
modified_at DATETIME NOT NULL,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
format TEXT,
|
||||||
|
favorite BOOLEAN DEFAULT FALSE,
|
||||||
|
rating INTEGER CHECK (rating >= 0 AND rating <= 5),
|
||||||
|
description TEXT,
|
||||||
|
metadata TEXT, -- JSON string
|
||||||
|
thumbnail_blob BLOB, -- Cached thumbnail image data
|
||||||
|
thumbnail_size INTEGER, -- Size parameter used for cached thumbnail
|
||||||
|
thumbnail_generated_at DATETIME, -- When thumbnail was generated
|
||||||
|
indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
conflict_detected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
original_photo_id TEXT, -- Reference to the original photo record
|
||||||
|
conflict_reason TEXT -- Why this was flagged as a conflict
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
// 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);
|
||||||
@ -152,6 +179,9 @@ function initializeTables(database: Database.Database) {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_photo_hashes_hash_id ON photo_hashes (hash_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_size ON photos (thumbnail_size);
|
||||||
CREATE INDEX IF NOT EXISTS idx_photos_thumbnail_generated_at ON photos (thumbnail_generated_at);
|
CREATE INDEX IF NOT EXISTS idx_photos_thumbnail_generated_at ON photos (thumbnail_generated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photo_conflicts_filepath ON photo_conflicts (filepath);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photo_conflicts_original_photo_id ON photo_conflicts (original_photo_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_photo_conflicts_detected_at ON photo_conflicts (conflict_detected_at);
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,16 +131,58 @@ async function processPhotoFile(
|
|||||||
let photoData: any = null
|
let photoData: any = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if photo already exists
|
stats = await stat(filePath)
|
||||||
const existingPhoto = photoService.getPhotoByPath(filePath)
|
|
||||||
if (existingPhoto) {
|
// Compute SHA256 hash first for conflict detection
|
||||||
|
console.log(`[FILE SCANNER] Computing SHA256 hash for: ${filename}`)
|
||||||
|
const sha256Hash = await computeFileHash(filePath)
|
||||||
|
console.log(`[FILE SCANNER] Computed hash for ${filename}: ${sha256Hash}`)
|
||||||
|
|
||||||
|
// Check for conflicts with existing photos
|
||||||
|
const conflictCheck = photoService.checkForPhotoConflict(filePath, sha256Hash)
|
||||||
|
|
||||||
|
if (conflictCheck.isDuplicate) {
|
||||||
|
console.info(`[FILE SCANNER] Skipping duplicate file (same path, same content): ${filename}`)
|
||||||
result.photosSkipped++
|
result.photosSkipped++
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stats = await stat(filePath)
|
if (conflictCheck.hasConflict && conflictCheck.existingPhoto) {
|
||||||
|
console.warn(`[FILE SCANNER] CONFLICT DETECTED: File ${filename} has same path but different content than existing photo`)
|
||||||
|
|
||||||
// Create basic photo record
|
// Create basic photo record for the conflicting file
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in conflicts table
|
||||||
|
const conflictReason = `File has same path as existing photo but different SHA256 hash. Original hash: ${conflictCheck.existingPhoto.id}, New hash: ${sha256Hash}`
|
||||||
|
const conflictId = photoService.createPhotoConflict(photoData, conflictCheck.existingPhoto.id, conflictReason)
|
||||||
|
console.warn(`[FILE SCANNER] Stored conflict record with ID: ${conflictId}`)
|
||||||
|
|
||||||
|
result.errors++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create basic photo record for new file
|
||||||
photoData = {
|
photoData = {
|
||||||
filename,
|
filename,
|
||||||
filepath: filePath,
|
filepath: filePath,
|
||||||
@ -177,12 +219,8 @@ async function processPhotoFile(
|
|||||||
result.photosAdded++
|
result.photosAdded++
|
||||||
console.log(`[FILE SCANNER] Successfully added photo: ${filename}`)
|
console.log(`[FILE SCANNER] Successfully added photo: ${filename}`)
|
||||||
|
|
||||||
// Compute and store SHA256 hash
|
// Store SHA256 hash
|
||||||
try {
|
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
|
// Create or update hash record
|
||||||
const hashRecord = photoService.createOrUpdateImageHash(sha256Hash)
|
const hashRecord = photoService.createOrUpdateImageHash(sha256Hash)
|
||||||
|
|
||||||
@ -191,8 +229,8 @@ async function processPhotoFile(
|
|||||||
console.log(`[FILE SCANNER] Associated photo with hash: ${associated}`)
|
console.log(`[FILE SCANNER] Associated photo with hash: ${associated}`)
|
||||||
|
|
||||||
} catch (hashError) {
|
} catch (hashError) {
|
||||||
console.error(`[FILE SCANNER] Error computing hash for ${filePath}:`, hashError)
|
console.error(`[FILE SCANNER] Error storing hash for ${filePath}:`, hashError)
|
||||||
// Continue processing even if hash computation fails
|
// Continue processing even if hash storage fails
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log progress every 100 files
|
// Log progress every 100 files
|
||||||
|
@ -426,6 +426,85 @@ export class PhotoService {
|
|||||||
return result.changes > 0
|
return result.changes > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Conflict detection and handling methods
|
||||||
|
checkForPhotoConflict(filepath: string, sha256Hash: string): { hasConflict: boolean, existingPhoto?: Photo, isDuplicate: boolean } {
|
||||||
|
const existingPhoto = this.getPhotoByPath(filepath)
|
||||||
|
|
||||||
|
if (!existingPhoto) {
|
||||||
|
return { hasConflict: false, isDuplicate: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the hash of the existing photo
|
||||||
|
const getPhotoHash = this.db.prepare(`
|
||||||
|
SELECT ih.sha256_hash
|
||||||
|
FROM photo_hashes ph
|
||||||
|
JOIN image_hashes ih ON ph.hash_id = ih.id
|
||||||
|
WHERE ph.photo_id = ?
|
||||||
|
`)
|
||||||
|
const existingHashRow = getPhotoHash.get(existingPhoto.id) as { sha256_hash: string } | null
|
||||||
|
|
||||||
|
if (!existingHashRow) {
|
||||||
|
// Existing photo has no hash, assume it's different content
|
||||||
|
return { hasConflict: true, existingPhoto, isDuplicate: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingHash = existingHashRow.sha256_hash
|
||||||
|
if (existingHash === sha256Hash) {
|
||||||
|
// Same file, same content - it's a duplicate
|
||||||
|
return { hasConflict: false, existingPhoto, isDuplicate: true }
|
||||||
|
} else {
|
||||||
|
// Same path, different content - it's a conflict
|
||||||
|
return { hasConflict: true, existingPhoto, isDuplicate: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createPhotoConflict(
|
||||||
|
photoData: Omit<Photo, 'id' | 'indexed_at' | 'thumbnail_blob' | 'thumbnail_size' | 'thumbnail_generated_at'>,
|
||||||
|
originalPhotoId: string,
|
||||||
|
conflictReason: string
|
||||||
|
): string {
|
||||||
|
const id = randomUUID()
|
||||||
|
const insertConflict = this.db.prepare(`
|
||||||
|
INSERT INTO photo_conflicts (
|
||||||
|
id, filename, filepath, directory, filesize, created_at, modified_at,
|
||||||
|
width, height, format, favorite, rating, description, metadata,
|
||||||
|
original_photo_id, conflict_reason
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 || false, // boolean
|
||||||
|
photoData.rating || null, // number or null
|
||||||
|
photoData.description || null, // string or null
|
||||||
|
photoData.metadata || null, // string (JSON) or null
|
||||||
|
originalPhotoId, // string
|
||||||
|
conflictReason // string
|
||||||
|
]
|
||||||
|
|
||||||
|
insertConflict.run(...params)
|
||||||
|
return id
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating photo conflict:', error)
|
||||||
|
throw new Error('Failed to create photo conflict record')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPhotoConflicts(): Array<Photo & { original_photo_id: string, conflict_reason: string, conflict_detected_at: string }> {
|
||||||
|
const selectConflicts = this.db.prepare('SELECT * FROM photo_conflicts ORDER BY conflict_detected_at DESC')
|
||||||
|
return selectConflicts.all() as Array<Photo & { original_photo_id: string, conflict_reason: string, conflict_detected_at: string }>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const photoService = new PhotoService()
|
export const photoService = new PhotoService()
|
Loading…
Reference in New Issue
Block a user