Missing Photos Implementation
Overview
The missing photos feature in LibrePhotos handles cases where photo files become unavailable on the file system while their metadata remains in the database. This document explains the architecture, implementation details, and how the system handles missing photos.
Architecture
Data Models
File Model
Located in librephotos/api/models/file.py, the File model represents individual files on disk:
class File(models.Model):
hash = models.CharField(primary_key=True, max_length=64)
path = models.TextField(blank=True, default="")
type = models.PositiveIntegerField(choices=FILE_TYPES)
missing = models.BooleanField(default=False) # Tracks if file is missing
embedded_media = models.ManyToManyField("File")
Key fields:
hash: MD5 hash of file content + user ID (primary key)path: Full file system pathmissing: Boolean flag indicating if the file cannot be foundtype: File type (IMAGE, VIDEO, METADATA_FILE, RAW_FILE, UNKNOWN)
Photo Model
Located in librephotos/api/models/photo.py, the Photo model has relationships to files:
class Photo(models.Model):
image_hash = models.CharField(primary_key=True, max_length=64)
files = models.ManyToManyField(File) # All associated files
main_file = models.ForeignKey(
File,
related_name="main_photo",
on_delete=models.SET_NULL,
null=True,
)
# ... other fields
A photo is considered missing when:
files=None(no associated files), ORmain_file=None(no primary file reference)
Query for missing photos:
missing_photos = Photo.objects.filter(
Q(owner=user) & Q(files=None) | Q(main_file=None)
)
Detection Mechanism
_check_files() Method
The Photo._check_files() method (lines 508-514 in librephotos/api/models/photo.py) is the core detection mechanism:
def _check_files(self):
for file in self.files.all():
if not file.path or not os.path.exists(file.path):
self.files.remove(file) # Remove from photo's file list
file.missing = True # Mark file as missing
file.save()
self.save()
This method:
- Iterates through all files associated with the photo
- Checks if the file path exists on the file system
- If missing, removes the file from the photo and sets
file.missing = True - Saves changes to the database
When is this called?
- During photo scans (
scan_missing_photosjob) - When adding new files to existing photos
- After detecting duplicate photos during import
Jobs
Scan Missing Photos Job
Type: JOB_SCAN_MISSING_PHOTOS (job type 14)
Function: scan_missing_photos(user, job_id) in librephotos/api/directory_watcher.py:356-386
Purpose: Checks all photos owned by a user to detect missing files
Implementation:
def scan_missing_photos(user, job_id: UUID):
# Create or update job entry
lrj = LongRunningJob.objects.create(
started_by=user,
job_id=job_id,
job_type=LongRunningJob.JOB_SCAN_MISSING_PHOTOS,
)
# Get all photos for user
existing_photos = Photo.objects.filter(owner=user.id).order_by("image_hash")
# Process in batches of 5000 for memory efficiency
paginator = Paginator(existing_photos, 5000)
lrj.progress_target = paginator.num_pages
for page in range(1, paginator.num_pages + 1):
for existing_photo in paginator.page(page).object_list:
existing_photo._check_files() # Check each photo's files
update_scan_counter(job_id)
Key features:
- Processes photos in batches of 5,000 to manage memory
- Updates progress counter for UI feedback
- Automatically triggered after full scans if not scanning specific files
Delete Missing Photos Job
Type: JOB_DELETE_MISSING_PHOTOS (job type 5)
Function: delete_missing_photos(user, job_id) in librephotos/api/autoalbum.py:188-232
Purpose: Permanently removes missing photos and their associated data from the database
Implementation:
def delete_missing_photos(user, job_id):
# Find all photos with no files or no main file
missing_photos = Photo.objects.filter(
Q(owner=user) & Q(files=None) | Q(main_file=None)
)
# Remove from all album types
for missing_photo in missing_photos:
album_dates = AlbumDate.objects.filter(photos=missing_photo)
for album_date in album_dates:
album_date.photos.remove(missing_photo)
album_things = AlbumThing.objects.filter(photos=missing_photo)
for album_thing in album_things:
album_thing.photos.remove(missing_photo)
album_places = AlbumPlace.objects.filter(photos=missing_photo)
for album_place in album_places:
album_place.photos.remove(missing_photo)
album_users = AlbumUser.objects.filter(photos=missing_photo)
for album_user in album_users:
album_user.photos.remove(missing_photo)
# Delete associated faces
faces = Face.objects.filter(photo=missing_photo)
faces.delete()
# Delete the photos
missing_photos.delete()
# Delete missing file records
missing_files = File.objects.filter(Q(hash__endswith=user) & Q(missing=True))
missing_files.delete()
What gets deleted:
- Photo records from database
- File records marked as missing
- Associations with date-based albums
- Associations with thing-based albums
- Associations with place-based albums
- Associations with user-created albums
- Face detections linked to the photos
What doesn't get deleted (TODO):
- Thumbnail files (line 221 notes: "To-Do: Remove thumbnails")
API Endpoints
Delete Missing Photos
Endpoint: POST /api/deletemissingphotos
Implementation: DeleteMissingPhotosView in librephotos/api/views/views.py:433-451
class DeleteMissingPhotosView(APIView):
def post(self, request, format=None):
try:
job_id = uuid.uuid4()
delete_missing_photos(request.user, job_id)
return Response({"status": True, "job_id": job_id})
except BaseException:
logger.exception("An Error occurred")
return Response({"status": False})
Response:
{
"status": true,
"job_id": "550e8400-e29b-41d4-a716-446655440000"
}
Note: Also supports GET method (deprecated) for backward compatibility.
Photo Statistics
Missing photo count is included in the user statistics API response.
Calculation: get_count_stats(user) in librephotos/api/stats.py:382-384
num_missing_photos = Photo.objects.filter(
Q(owner=user) & Q(files=None) | Q(main_file=None)
).count()
Returned in stats response as:
{
"num_photos": 1234,
"num_missing_photos": 5,
// ... other stats
}
Hash-Based Relinking
How It Works
When files reappear in the scanned directories (even with different names or paths), LibrePhotos can automatically relink them using hash-based matching.
Hash Calculation: calculate_hash(user, path) in librephotos/api/models/file.py:136-145
def calculate_hash(user, path):
hash_md5 = hashlib.md5()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(BUFFER_SIZE), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest() + str(user.id)
Key points:
- Hash is MD5 of file content + user ID
- User ID ensures photos are scoped to individual users in multi-user setups
- Buffer size of 65536 bytes for optimal performance
Relinking Process
Function: create_new_image(user, path) in librephotos/api/directory_watcher.py:62-136
def create_new_image(user, path) -> Photo | None:
# Calculate hash for the file
hash = calculate_hash(user, path)
# Check if photo with this hash already exists
photos: QuerySet[Photo] = Photo.objects.filter(image_hash=hash, owner=user)
if not photos.exists():
# Create new photo
photo = Photo()
photo.image_hash = hash
# ... initialize photo
else:
# Photo exists - add this file to it (relinking)
file = File.create(path, user)
photo = photos.first()
photo.files.add(file)
# Restore if previously marked as removed
if photo.removed:
photo.removed = False
photo.in_trashcan = False
photo.save()
photo._check_files() # Verify all files still exist
return photo
Relinking behavior:
- Calculate hash of discovered file
- Query for existing photos with same hash
- If found, add new file to existing photo
- Restore photo if it was marked as removed
- Run
_check_files()to clean up any still-missing files
Frontend Integration
Photo Serializer
File: librephotos/api/serializers/photos.py:348
The serializer includes a "Missing" label for photos without files:
def get_image_path(self, obj) -> list[str]:
if not obj.files or obj.files.count() == 0:
return ["Missing"]
return [file.path for file in obj.files.all()]
This ensures the frontend can display appropriate UI for missing photos.
Video Error Handling
File: librephotos-frontend/src/components/lightbox/MediaDisplay.tsx:64-69
The frontend displays an error alert when video files are missing:
if (videoError) {
return (
<Alert color="red" title="Video Not Found">
<Text>The video file could not be found or is no longer available.</Text>
</Alert>
);
}
Future Implementation
Real-Time File System Monitoring
Goal: Eliminate most missing photo cases through proactive file tracking
Planned features:
File System Watchers
- Implement inotify (Linux), FSEvents (macOS), or watchdog library
- Monitor scanned directories for file changes in real-time
- Trigger immediate updates instead of waiting for manual scans
Move/Rename Detection
- Detect when files are moved within scanned directories
- Automatically update file paths in database
- Preserve all metadata, ratings, and associations
Immediate Relinking
- Hash-based matching happens immediately when files appear
- No manual scan required
- Significantly reduced "missing photo" window
Benefits
- Near-instant UI updates when files change
- Reduced database queries (no periodic scanning)
- Better user experience with fewer missing photos
- Lower system resource usage
Implementation Considerations
- Performance impact of continuous monitoring
- Handling large directory trees efficiently
- Network storage compatibility (NAS, SMB, NFS)
- Docker container file system event propagation
- Graceful degradation if monitoring unavailable
Known Issues and TODOs
Current TODOs
Remove thumbnails (line 221 in
autoalbum.py)- When deleting missing photos, thumbnail files are not removed
- Thumbnails remain in
data/thumbnails/directory - Should be cleaned up to free disk space
Move delete_missing_photos function (line 187 in
autoalbum.py)- Currently in
autoalbum.pybut doesn't belong there - Should be moved to appropriate module (e.g.,
photo_operations.pyor similar) - Comment says: "To-Do: This does not belong here"
- Currently in
Edge Cases
- Symbolic links: May not be handled correctly in all cases
- Network storage timeouts: Slow network storage may cause false positives
- Permissions: Permission changes could make files appear missing
- Race conditions: Files changed during scan may cause inconsistencies
Code Organization
Key Files
Models:
librephotos/api/models/file.py- File model with missing flaglibrephotos/api/models/photo.py- Photo model and_check_files()methodlibrephotos/api/models/long_running_job.py- Job type definitions
Business Logic:
librephotos/api/directory_watcher.py- Scanning and relinking logiclibrephotos/api/autoalbum.py- Delete missing photos function
API Views:
librephotos/api/views/views.py- Delete missing photos endpointlibrephotos/api/views/photos.py- Photo operations
Statistics:
librephotos/api/stats.py- Count calculations including missing photos
Serializers:
librephotos/api/serializers/photos.py- Photo serialization with "Missing" label
Testing Considerations
When testing missing photos functionality:
- Setup: Create photos with valid files
- Trigger: Remove files from file system (outside LibrePhotos)
- Scan: Run
scan_missing_photosjob - Verify: Check that photos marked as missing
- Restore: Add files back and rescan
- Verify relinking: Ensure photos automatically relink
- Delete: Test permanent deletion with
delete_missing_photos
Related Documentation
- Photo List Implementation - Understanding photo queries and display
- Thumbnails - How thumbnails are generated and stored
- Upload System - File handling during uploads