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

- 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:
tanyar09
2026-02-04 19:30:05 +00:00
parent 46dffc6ade
commit 7a981b069a
16 changed files with 648 additions and 89 deletions
+139 -4
View File
@@ -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}")