feat: Enhance logging and error handling for job streaming and photo uploads
CI / skip-ci-check (pull_request) Successful in 8s
CI / lint-and-type-check (pull_request) Successful in 1m12s
CI / python-lint (pull_request) Successful in 36s
CI / test-backend (pull_request) Successful in 3m47s
CI / build (pull_request) Successful in 3m28s
CI / secret-scanning (pull_request) Successful in 14s
CI / dependency-scan (pull_request) Successful in 13s
CI / sast-scan (pull_request) Successful in 1m33s
CI / workflow-summary (pull_request) Successful in 5s
CI / skip-ci-check (pull_request) Successful in 8s
CI / lint-and-type-check (pull_request) Successful in 1m12s
CI / python-lint (pull_request) Successful in 36s
CI / test-backend (pull_request) Successful in 3m47s
CI / build (pull_request) Successful in 3m28s
CI / secret-scanning (pull_request) Successful in 14s
CI / dependency-scan (pull_request) Successful in 13s
CI / sast-scan (pull_request) Successful in 1m33s
CI / workflow-summary (pull_request) Successful in 5s
- Added new logging scripts for quick access to service logs and troubleshooting. - Updated job streaming API to support authentication via query parameters for EventSource. - Improved photo upload process to capture and validate EXIF dates and original modification times. - Enhanced error handling for file uploads and EXIF extraction failures. - Introduced new configuration options in ecosystem.config.js to prevent infinite crash loops.
This commit is contained in:
+139
-4
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status, Request
|
||||
from fastapi.responses import JSONResponse, FileResponse, Response
|
||||
from typing import Annotated
|
||||
from rq import Queue
|
||||
@@ -339,6 +339,7 @@ def search_photos(
|
||||
@router.post("/import", response_model=PhotoImportResponse)
|
||||
def import_photos(
|
||||
request: PhotoImportRequest,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
) -> PhotoImportResponse:
|
||||
"""Import photos from a folder path.
|
||||
|
||||
@@ -381,7 +382,7 @@ def import_photos(
|
||||
|
||||
@router.post("/import/upload")
|
||||
async def upload_photos(
|
||||
files: list[UploadFile] = File(...),
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Upload photo files directly.
|
||||
@@ -393,6 +394,7 @@ async def upload_photos(
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime, date
|
||||
|
||||
from backend.settings import PHOTO_STORAGE_DIR
|
||||
|
||||
@@ -404,6 +406,49 @@ async def upload_photos(
|
||||
existing_count = 0
|
||||
errors = []
|
||||
|
||||
# Read form data first to get both files and metadata
|
||||
form_data = await request.form()
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Extract file metadata (EXIF dates and original modification timestamps) from form data
|
||||
# These are captured from the ORIGINAL file BEFORE upload, so they preserve the real dates
|
||||
file_original_mtime = {}
|
||||
file_exif_dates = {}
|
||||
files = []
|
||||
|
||||
# Extract files first using getlist (handles multiple files with same key)
|
||||
files = form_data.getlist('files')
|
||||
|
||||
# Extract metadata from form data
|
||||
for key, value in form_data.items():
|
||||
if key.startswith('file_exif_date_'):
|
||||
# Extract EXIF date from browser (format: file_exif_date_<filename>)
|
||||
filename = key.replace('file_exif_date_', '')
|
||||
file_exif_dates[filename] = str(value)
|
||||
elif key.startswith('file_original_mtime_'):
|
||||
# Extract original file modification time from browser (format: file_original_mtime_<filename>)
|
||||
# This is the modification date from the ORIGINAL file before upload
|
||||
filename = key.replace('file_original_mtime_', '')
|
||||
try:
|
||||
file_original_mtime[filename] = int(value)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.debug(f"Could not parse original mtime for {filename}: {e}")
|
||||
|
||||
# If no files found in form_data, try to get them from request directly
|
||||
if not files:
|
||||
# Fallback: try to get files from request.files() if available
|
||||
try:
|
||||
if hasattr(request, '_form'):
|
||||
form = await request.form()
|
||||
files = form.getlist('files')
|
||||
except:
|
||||
pass
|
||||
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="No files provided")
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
# Generate unique filename to avoid conflicts
|
||||
@@ -418,8 +463,63 @@ async def upload_photos(
|
||||
with open(stored_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Extract date metadata from browser BEFORE upload
|
||||
# Priority: 1) Browser EXIF date, 2) Original file modification date (from before upload)
|
||||
# This ensures we use the ORIGINAL file's metadata, not the server's copy
|
||||
browser_exif_date = None
|
||||
file_last_modified = None
|
||||
|
||||
# First try: Use EXIF date extracted in browser (from original file)
|
||||
if file.filename in file_exif_dates:
|
||||
exif_date_str = file_exif_dates[file.filename]
|
||||
logger.info(f"[UPLOAD] Found browser EXIF date for {file.filename}: {exif_date_str}")
|
||||
try:
|
||||
# Parse EXIF date string (format: "YYYY:MM:DD HH:MM:SS" or ISO format)
|
||||
from dateutil import parser
|
||||
exif_datetime = parser.parse(exif_date_str)
|
||||
browser_exif_date = exif_datetime.date()
|
||||
# Validate the date
|
||||
if browser_exif_date > date.today() or browser_exif_date < date(1900, 1, 1):
|
||||
logger.warning(f"[UPLOAD] Browser EXIF date {browser_exif_date} is invalid for {file.filename}, trying original mtime")
|
||||
browser_exif_date = None
|
||||
else:
|
||||
logger.info(f"[UPLOAD] Parsed browser EXIF date: {browser_exif_date} for {file.filename}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[UPLOAD] Could not parse browser EXIF date '{exif_date_str}' for {file.filename}: {e}, trying original mtime")
|
||||
browser_exif_date = None
|
||||
else:
|
||||
logger.debug(f"[UPLOAD] No browser EXIF date found for {file.filename}")
|
||||
|
||||
# Second try: Use original file modification time (captured BEFORE upload)
|
||||
if file.filename in file_original_mtime:
|
||||
timestamp_ms = file_original_mtime[file.filename]
|
||||
logger.info(f"[UPLOAD] Found original mtime for {file.filename}: {timestamp_ms}")
|
||||
try:
|
||||
file_last_modified = datetime.fromtimestamp(timestamp_ms / 1000.0).date()
|
||||
# Validate the date
|
||||
if file_last_modified > date.today() or file_last_modified < date(1900, 1, 1):
|
||||
logger.warning(f"[UPLOAD] Original file mtime {file_last_modified} is invalid for {file.filename}")
|
||||
file_last_modified = None
|
||||
else:
|
||||
logger.info(f"[UPLOAD] Parsed original mtime: {file_last_modified} for {file.filename}")
|
||||
except (ValueError, OSError) as e:
|
||||
logger.warning(f"[UPLOAD] Could not parse original mtime timestamp {timestamp_ms} for {file.filename}: {e}")
|
||||
file_last_modified = None
|
||||
else:
|
||||
logger.debug(f"[UPLOAD] No original mtime found for {file.filename}")
|
||||
|
||||
logger.info(f"[UPLOAD] Calling import_photo_from_path for {file.filename} with browser_exif_date={browser_exif_date}, file_last_modified={file_last_modified}")
|
||||
# Import photo from stored location
|
||||
photo, is_new = import_photo_from_path(db, str(stored_path))
|
||||
# Pass browser-extracted EXIF date and file modification time separately
|
||||
# Priority: browser_exif_date > server EXIF extraction > file_last_modified
|
||||
photo, is_new = import_photo_from_path(
|
||||
db,
|
||||
str(stored_path),
|
||||
is_uploaded_file=True,
|
||||
file_last_modified=file_last_modified,
|
||||
browser_exif_date=browser_exif_date
|
||||
)
|
||||
|
||||
if is_new:
|
||||
added_count += 1
|
||||
else:
|
||||
@@ -982,8 +1082,18 @@ def bulk_delete_photos(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> BulkDeletePhotosResponse:
|
||||
"""Delete multiple photos and all related data (faces, encodings, tags, favorites)."""
|
||||
"""Delete multiple photos and all related data (faces, encodings, tags, favorites).
|
||||
|
||||
If a photo's file is in the uploads folder, it will also be deleted from the filesystem
|
||||
to prevent duplicate uploads.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from backend.db.models import Photo, PhotoTagLinkage
|
||||
from backend.settings import PHOTO_STORAGE_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
photo_ids = list(dict.fromkeys(request.photo_ids))
|
||||
if not photo_ids:
|
||||
@@ -992,13 +1102,36 @@ def bulk_delete_photos(
|
||||
detail="photo_ids list cannot be empty",
|
||||
)
|
||||
|
||||
# Get the uploads folder path for comparison
|
||||
uploads_dir = Path(PHOTO_STORAGE_DIR).resolve()
|
||||
|
||||
try:
|
||||
photos = db.query(Photo).filter(Photo.id.in_(photo_ids)).all()
|
||||
found_ids = {photo.id for photo in photos}
|
||||
missing_ids = sorted(set(photo_ids) - found_ids)
|
||||
|
||||
deleted_count = 0
|
||||
files_deleted_count = 0
|
||||
for photo in photos:
|
||||
# Only delete file from filesystem if it's directly in the uploads folder
|
||||
# Do NOT delete files from other folders (main photo storage, etc.)
|
||||
photo_path = Path(photo.path).resolve()
|
||||
# Strict check: only delete if parent directory is exactly the uploads folder
|
||||
if photo_path.parent == uploads_dir:
|
||||
try:
|
||||
if photo_path.exists():
|
||||
os.remove(photo_path)
|
||||
files_deleted_count += 1
|
||||
logger.warning(f"DELETED file from uploads folder: {photo_path} (Photo ID: {photo.id})")
|
||||
else:
|
||||
logger.warning(f"Photo file not found (already deleted?): {photo_path} (Photo ID: {photo.id})")
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to delete file {photo_path} (Photo ID: {photo.id}): {e}")
|
||||
# Continue with database deletion even if file deletion fails
|
||||
else:
|
||||
# File is not in uploads folder - do not delete from filesystem
|
||||
logger.info(f"Photo {photo.id} is not in uploads folder (path: {photo_path.parent}, uploads: {uploads_dir}), skipping file deletion")
|
||||
|
||||
# Remove tag linkages explicitly (in addition to cascade) to keep counts accurate
|
||||
db.query(PhotoTagLinkage).filter(
|
||||
PhotoTagLinkage.photo_id == photo.id
|
||||
@@ -1019,6 +1152,8 @@ def bulk_delete_photos(
|
||||
|
||||
admin_username = current_admin.get("username", "unknown")
|
||||
message_parts = [f"Deleted {deleted_count} photo(s)"]
|
||||
if files_deleted_count > 0:
|
||||
message_parts.append(f"{files_deleted_count} file(s) removed from uploads folder")
|
||||
if missing_ids:
|
||||
message_parts.append(f"{len(missing_ids)} photo(s) not found")
|
||||
message_parts.append(f"Request by admin: {admin_username}")
|
||||
|
||||
Reference in New Issue
Block a user