""" Celery tasks for scanning folders and indexing photos """ import os import hashlib import asyncio from pathlib import Path from datetime import datetime, timezone import logging import json from typing import List, Dict, Optional from celery import shared_task from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession import aiofiles import redis from app.database import AsyncSessionLocal from app.models import Photo, Folder, SourceRoot from app.config import settings from app.tasks.thumbs import generate_thumbnails from app.tasks.video import pretranscode_video from app.services.metadata import extract_metadata from app.services.date_guess import has_date_warning logger = logging.getLogger(__name__) # Redis keys read by GET /api/v1/library/scan/status. The frontend # ScanProgress widget polls that endpoint, so anything we want to surface # in the UI lives here. REDIS_KEY_ACTIVE = 'scan:active' REDIS_KEY_CURRENT_FOLDER = 'scan:current_folder' REDIS_KEY_PROCESSED = 'scan:processed_files' REDIS_KEY_TOTAL = 'scan:total_files' REDIS_KEY_ERRORS = 'scan:errors' MAX_ERROR_ENTRIES = 50 # cap the errors list so a noisy scan doesn't blow Redis def _get_redis(): """Connect to the broker for progress writes. Returns None on failure so a Redis outage doesn't prevent the scan itself from running.""" try: return redis.Redis.from_url(settings.celery_broker_url) except Exception as e: logger.warning(f"Could not reach Redis for scan progress: {e}") return None # Supported file extensions PHOTO_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.webp', '.bmp'} RAW_EXTENSIONS = {'.cr2', '.cr3', '.nef', '.arw', '.raf', '.dng', '.orf', '.rw2', '.pef', '.srw'} HEIC_EXTENSIONS = {'.heic', '.heif'} VIDEO_EXTENSIONS = {'.mp4', '.mov', '.avi', '.mkv', '.mts', '.m2ts', '.3gp', '.wmv', '.flv'} SUPPORTED_EXTENSIONS = PHOTO_EXTENSIONS | RAW_EXTENSIONS | HEIC_EXTENSIONS | VIDEO_EXTENSIONS def get_media_type(filepath: str) -> str: """Determine media type from file extension""" ext = Path(filepath).suffix.lower() if ext in PHOTO_EXTENSIONS: return 'photo' elif ext in RAW_EXTENSIONS: return 'raw' elif ext in HEIC_EXTENSIONS: return 'heic' elif ext in VIDEO_EXTENSIONS: return 'video' return 'unknown' async def calculate_file_hash(filepath: str) -> str: """Calculate SHA-256 hash of a file""" hash_sha256 = hashlib.sha256() try: async with aiofiles.open(filepath, 'rb') as f: while chunk := await f.read(8192): hash_sha256.update(chunk) return hash_sha256.hexdigest() except Exception as e: logger.error(f"Error calculating hash for {filepath}: {e}") return "" @shared_task(bind=True, name='scan_folder') def scan_folder(self, folder_path: str, source_root_id: Optional[str] = None): """ Scan a folder and index all photos/videos """ # Run async function in sync context return asyncio.run(_scan_folder_async(folder_path, source_root_id, self)) async def _scan_folder_async(folder_path: str, source_root_id: Optional[str], task): """Async implementation of folder scanning. Writes progress to Redis so GET /api/v1/library/scan/status can surface it to the frontend ScanProgress widget.""" logger.info(f"Starting scan of folder: {folder_path}") r = _get_redis() PROGRESS_TTL = 3600 # 1 hour — auto-expire if scan crashes def progress_set(key: str, value) -> None: if r is None: return try: r.set(key, str(value), ex=PROGRESS_TTL) except Exception as e: logger.debug(f"scan progress set failed: {e}") def progress_push_error(message: str) -> None: if r is None: return try: r.lpush(REDIS_KEY_ERRORS, message) r.ltrim(REDIS_KEY_ERRORS, 0, MAX_ERROR_ENTRIES - 1) except Exception as e: logger.debug(f"scan progress push_error failed: {e}") # Mark scan active immediately so the UI starts polling fast. progress_set(REDIS_KEY_ACTIVE, 'true') progress_set(REDIS_KEY_CURRENT_FOLDER, folder_path) async with AsyncSessionLocal() as session: try: # Get or create source root if not source_root_id: source_root = await get_or_create_source_root(session, folder_path) source_root_id = source_root.id else: source_root = (await session.execute( select(SourceRoot).where(SourceRoot.id == source_root_id) )).scalar_one_or_none() # Inherit user_id from the source root's owner owner_user_id = source_root.user_id if source_root else None # Per-scan memoization cache for "is this folder's effective # is_hidden true?" Populated on first lookup by walking the # parent_id chain up to the source root. Keyed by folder_id # so repeated photos in the same folder pay only one lookup. hidden_folder_cache: dict[str, bool] = {} async def is_folder_effectively_hidden(folder_row: Folder) -> bool: if folder_row.id in hidden_folder_cache: return hidden_folder_cache[folder_row.id] # Walk parents. If the current folder is hidden, short- # circuit. Otherwise climb until we hit a root (no # parent_id) or a cached ancestor. if folder_row.is_hidden: hidden_folder_cache[folder_row.id] = True return True parent_id = folder_row.parent_id while parent_id is not None: if parent_id in hidden_folder_cache: hidden_folder_cache[folder_row.id] = hidden_folder_cache[parent_id] return hidden_folder_cache[folder_row.id] parent = ( await session.execute( select(Folder).where(Folder.id == parent_id) ) ).scalar_one_or_none() if parent is None: break if parent.is_hidden: hidden_folder_cache[folder_row.id] = True return True parent_id = parent.parent_id hidden_folder_cache[folder_row.id] = False return False # Pre-walk to compute the total file count upfront. Without this # the progress bar would jump every time a new subfolder is # encountered because the running total kept growing. total_files = 0 for _root, _dirs, files in os.walk(folder_path): total_files += sum( 1 for f in files if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS ) progress_set(REDIS_KEY_TOTAL, total_files) progress_set(REDIS_KEY_PROCESSED, 0) processed_files = 0 errors = [] for root, dirs, files in os.walk(folder_path): # Get or create folder entry folder = await get_or_create_folder(session, root, source_root_id, owner_user_id) progress_set(REDIS_KEY_CURRENT_FOLDER, root) # Filter supported files supported_files = [f for f in files if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS] # Process files in batches batch_size = settings.scanner.batch_size for i in range(0, len(supported_files), batch_size): batch = supported_files[i:i + batch_size] # Defer task dispatch until AFTER commit so workers don't # query for rows that aren't visible to other sessions yet. pending_dispatch: list[str] = [] pending_video_pretranscode: list[tuple[str, str]] = [] for filename in batch: filepath = os.path.join(root, filename) try: # Check if file already exists in database existing = await session.execute( select(Photo).where(Photo.filepath == filepath) ) existing_photo = existing.scalar_one_or_none() if existing_photo is not None: # Resurrect a previously-discarded row only # when the file's mtime is newer than # discarded_at. Bare existence on disk isn't # proof the user changed their mind: every # backend boot fires scan_all_source_roots, # which used to walk every file and silently # un-discard the lot. The mtime check still # covers the legitimate flows (WebDAV DELETE # + re-upload, trashbin restore via PUT- # overwrite, any "I removed it then put it # back") because those rewrite the file and # bump mtime past the discard time. Rows # with discarded_at IS NULL (legacy) are # left alone — preserve user intent over # best-effort cleanup. if existing_photo.is_discarded: discarded_at = existing_photo.discarded_at try: mtime = os.path.getmtime(filepath) except OSError: mtime = 0.0 file_modified_after_discard = ( discarded_at is not None and mtime > discarded_at.replace( tzinfo=timezone.utc ).timestamp() ) if file_modified_after_discard: existing_photo.is_discarded = False existing_photo.discarded_at = None await session.commit() logger.info( f"Resurrected discarded photo " f"(file modified after discard): " f"{filepath}" ) extract_metadata.delay(existing_photo.id) else: logger.debug( f"Skipping discarded photo " f"(file unchanged since discard): " f"{filepath}" ) else: logger.debug(f"File already indexed: {filepath}") processed_files += 1 progress_set(REDIS_KEY_PROCESSED, processed_files) continue # Get file stats stat = os.stat(filepath) # Calculate file hash for duplicate detection file_hash = await calculate_file_hash(filepath) # Check for duplicate by hash. We only care # whether *any* other photo shares this hash, so # use a count rather than scalar_one_or_none() # which raises "Multiple rows were found" the # moment the library has 2+ copies of the same # file (i.e. exactly the case we're trying to # flag). is_dup = False if file_hash: dup_count = (await session.execute( select(func.count(Photo.id)).where( Photo.file_hash == file_hash ) )).scalar() or 0 is_dup = dup_count > 0 # Inherit the effective-hidden flag from the # folder's ancestry. If any ancestor folder # has is_hidden=true, the new photo is # immediately marked hidden so it never # briefly appears in cross-cutting views # between scan and the next manual recompute. effective_hidden = await is_folder_effectively_hidden(folder) # Create photo entry mtime_dt = datetime.fromtimestamp(stat.st_mtime) photo = Photo( filepath=filepath, filename=filename, folder_id=folder.id, user_id=owner_user_id, file_hash=file_hash, media_type=get_media_type(filepath), original_format=Path(filepath).suffix.upper()[1:], file_size=stat.st_size, taken_at=mtime_dt, taken_at_source='filesystem', # First-pass flag based on the filesystem mtime; # metadata.extract_metadata re-runs this once # EXIF has been parsed so a real DateTimeOriginal # can clear the warning. has_date_warning=has_date_warning(filepath, mtime_dt), is_duplicate=is_dup, is_hidden=effective_hidden, processing_status='pending' ) session.add(photo) await session.flush() # Assign defaults / FK ids # Queue dispatch happens after the batch commit # below; otherwise the worker can race the writer # and see "Photo not found". pending_dispatch.append(photo.id) if photo.media_type == 'video': # Pre-transcode HEVC and other non-web-safe # videos at scan time so the user doesn't # pay the encode cost on first