This commit introduces a folder browsing button in the Dashboard GUI, allowing users to select a folder for photo scanning. It also implements path normalization and validation using new utility functions from the path_utils module, ensuring that folder paths are absolute and accessible before scanning. Additionally, the PhotoManager class has been updated to utilize these path utilities, enhancing the robustness of folder scanning operations. This improves user experience by preventing errors related to invalid paths and streamlining folder management across the application.
229 lines
8.8 KiB
Python
229 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Photo scanning, metadata extraction, and file operations for PunimTag
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from PIL import Image
|
|
from datetime import datetime
|
|
from typing import Optional, List, Tuple
|
|
|
|
from config import SUPPORTED_IMAGE_FORMATS
|
|
from database import DatabaseManager
|
|
from path_utils import normalize_path, validate_path_exists
|
|
|
|
|
|
class PhotoManager:
|
|
"""Handles photo scanning, metadata extraction, and file operations"""
|
|
|
|
def __init__(self, db_manager: DatabaseManager, verbose: int = 0):
|
|
"""Initialize photo manager"""
|
|
self.db = db_manager
|
|
self.verbose = verbose
|
|
|
|
def extract_photo_date(self, photo_path: str) -> Optional[str]:
|
|
"""Extract date taken from photo EXIF data"""
|
|
try:
|
|
with Image.open(photo_path) as image:
|
|
exifdata = image.getexif()
|
|
|
|
# Look for date taken in EXIF tags
|
|
date_tags = [
|
|
306, # DateTime
|
|
36867, # DateTimeOriginal
|
|
36868, # DateTimeDigitized
|
|
]
|
|
|
|
for tag_id in date_tags:
|
|
if tag_id in exifdata:
|
|
date_str = exifdata[tag_id]
|
|
if date_str:
|
|
# Parse EXIF date format (YYYY:MM:DD HH:MM:SS)
|
|
try:
|
|
date_obj = datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S')
|
|
return date_obj.strftime('%Y-%m-%d')
|
|
except ValueError:
|
|
# Try alternative format
|
|
try:
|
|
date_obj = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
|
|
return date_obj.strftime('%Y-%m-%d')
|
|
except ValueError:
|
|
continue
|
|
|
|
return None
|
|
except Exception as e:
|
|
if self.verbose >= 2:
|
|
print(f" ⚠️ Could not extract date from {os.path.basename(photo_path)}: {e}")
|
|
return None
|
|
|
|
def scan_folder(self, folder_path: str, recursive: bool = True) -> int:
|
|
"""Scan folder for photos and add to database"""
|
|
# Normalize path to absolute path
|
|
try:
|
|
folder_path = normalize_path(folder_path)
|
|
except ValueError as e:
|
|
print(f"❌ Invalid path: {e}")
|
|
return 0
|
|
|
|
if not validate_path_exists(folder_path):
|
|
print(f"❌ Folder not found or not accessible: {folder_path}")
|
|
return 0
|
|
|
|
found_photos = []
|
|
|
|
if recursive:
|
|
for root, dirs, files in os.walk(folder_path):
|
|
for file in files:
|
|
file_ext = Path(file).suffix.lower()
|
|
if file_ext in SUPPORTED_IMAGE_FORMATS:
|
|
photo_path = os.path.join(root, file)
|
|
found_photos.append((photo_path, file))
|
|
else:
|
|
for file in os.listdir(folder_path):
|
|
file_ext = Path(file).suffix.lower()
|
|
if file_ext in SUPPORTED_IMAGE_FORMATS:
|
|
photo_path = os.path.join(folder_path, file)
|
|
found_photos.append((photo_path, file))
|
|
|
|
if not found_photos:
|
|
print(f"📁 No photos found in {folder_path}")
|
|
return 0
|
|
|
|
# Add to database
|
|
added_count = 0
|
|
existing_count = 0
|
|
|
|
for photo_path, filename in found_photos:
|
|
try:
|
|
# Ensure photo path is absolute
|
|
photo_path = normalize_path(photo_path)
|
|
|
|
# Extract date taken from EXIF data
|
|
date_taken = self.extract_photo_date(photo_path)
|
|
|
|
# Add photo to database (with absolute path)
|
|
photo_id = self.db.add_photo(photo_path, filename, date_taken)
|
|
if photo_id:
|
|
# New photo was added
|
|
added_count += 1
|
|
if self.verbose >= 2:
|
|
date_info = f" (taken: {date_taken})" if date_taken else " (no date)"
|
|
print(f" 📸 Added: {filename}{date_info}")
|
|
else:
|
|
# Photo already exists
|
|
existing_count += 1
|
|
if self.verbose >= 2:
|
|
print(f" 📸 Already exists: {filename}")
|
|
except Exception as e:
|
|
print(f"⚠️ Error adding {filename}: {e}")
|
|
|
|
# Print summary
|
|
if added_count > 0 and existing_count > 0:
|
|
print(f"📁 Found {len(found_photos)} photos: {added_count} new, {existing_count} already in database")
|
|
elif added_count > 0:
|
|
print(f"📁 Found {len(found_photos)} photos, added {added_count} new photos")
|
|
elif existing_count > 0:
|
|
print(f"📁 Found {len(found_photos)} photos, all already in database")
|
|
else:
|
|
print(f"📁 Found {len(found_photos)} photos, none could be added")
|
|
|
|
return added_count
|
|
|
|
def get_photo_info(self, photo_id: int) -> Optional[Tuple]:
|
|
"""Get photo information by ID"""
|
|
photos = self.db.get_photos_by_pattern(limit=1000) # Get all photos
|
|
for photo in photos:
|
|
if photo[0] == photo_id: # photo[0] is the ID
|
|
return photo
|
|
return None
|
|
|
|
def get_photo_path(self, photo_id: int) -> Optional[str]:
|
|
"""Get photo path by ID"""
|
|
photo_info = self.get_photo_info(photo_id)
|
|
return photo_info[1] if photo_info else None # photo[1] is the path
|
|
|
|
def get_photo_filename(self, photo_id: int) -> Optional[str]:
|
|
"""Get photo filename by ID"""
|
|
photo_info = self.get_photo_info(photo_id)
|
|
return photo_info[2] if photo_info else None # photo[2] is the filename
|
|
|
|
def is_photo_processed(self, photo_id: int) -> bool:
|
|
"""Check if photo has been processed for faces"""
|
|
photo_info = self.get_photo_info(photo_id)
|
|
return photo_info[4] if photo_info else False # photo[4] is the processed flag
|
|
|
|
def mark_photo_processed(self, photo_id: int):
|
|
"""Mark a photo as processed"""
|
|
self.db.mark_photo_processed(photo_id)
|
|
|
|
def get_photos_by_date_range(self, date_from: str = None, date_to: str = None) -> List[Tuple]:
|
|
"""Get photos within a date range"""
|
|
# This would need to be implemented in the database module
|
|
# For now, return all photos
|
|
return self.db.get_photos_by_pattern()
|
|
|
|
def get_photos_by_pattern(self, pattern: str = None, limit: int = 10) -> List[Tuple]:
|
|
"""Get photos matching a pattern"""
|
|
return self.db.get_photos_by_pattern(pattern, limit)
|
|
|
|
def validate_photo_file(self, photo_path: str) -> bool:
|
|
"""Validate that a photo file exists and is readable"""
|
|
if not os.path.exists(photo_path):
|
|
return False
|
|
|
|
try:
|
|
with Image.open(photo_path) as image:
|
|
image.verify()
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def get_photo_dimensions(self, photo_path: str) -> Optional[Tuple[int, int]]:
|
|
"""Get photo dimensions (width, height)"""
|
|
try:
|
|
with Image.open(photo_path) as image:
|
|
return image.size
|
|
except Exception:
|
|
return None
|
|
|
|
def get_photo_format(self, photo_path: str) -> Optional[str]:
|
|
"""Get photo format"""
|
|
try:
|
|
with Image.open(photo_path) as image:
|
|
return image.format
|
|
except Exception:
|
|
return None
|
|
|
|
def get_photo_exif_data(self, photo_path: str) -> dict:
|
|
"""Get EXIF data from photo"""
|
|
try:
|
|
with Image.open(photo_path) as image:
|
|
exifdata = image.getexif()
|
|
return dict(exifdata)
|
|
except Exception:
|
|
return {}
|
|
|
|
def get_photo_file_size(self, photo_path: str) -> Optional[int]:
|
|
"""Get photo file size in bytes"""
|
|
try:
|
|
return os.path.getsize(photo_path)
|
|
except Exception:
|
|
return None
|
|
|
|
def get_photo_creation_time(self, photo_path: str) -> Optional[datetime]:
|
|
"""Get photo file creation time"""
|
|
try:
|
|
timestamp = os.path.getctime(photo_path)
|
|
return datetime.fromtimestamp(timestamp)
|
|
except Exception:
|
|
return None
|
|
|
|
def get_photo_modification_time(self, photo_path: str) -> Optional[datetime]:
|
|
"""Get photo file modification time"""
|
|
try:
|
|
timestamp = os.path.getmtime(photo_path)
|
|
return datetime.fromtimestamp(timestamp)
|
|
except Exception:
|
|
return None
|