""" EXIF write-back helpers. The rest of the app reads EXIF at scan time and stashes the result in Postgres (see `services/metadata.py`). This module handles the reverse direction: when the user corrects a date in the UI we also rewrite the relevant EXIF tags on disk so a later rescan won't clobber the fix and external tools see the same truth the DB does. """ import asyncio import logging import subprocess from datetime import datetime from pathlib import Path logger = logging.getLogger(__name__) EXIFTOOL_TIMEOUT_SECONDS = 30 class ExifWriteError(RuntimeError): """Raised when exiftool fails to write tags to a file.""" def _format_exif_dt(dt: datetime) -> str: return dt.strftime("%Y:%m:%d %H:%M:%S") async def write_taken_at(filepath: str, dt: datetime) -> None: """Rewrite DateTimeOriginal / CreateDate / ModifyDate on the file. - ``-overwrite_original`` so we don't litter the library with ``_original`` sidecars. - ``-P`` preserves the file's mtime so the scanner's mtime-based change detection stays quiet. - We set all three common date tags together because different viewers read different ones; keeping them in lockstep avoids confusing downstream tools and our own re-extraction pass. """ if not Path(filepath).exists(): raise ExifWriteError(f"File not found: {filepath}") stamp = _format_exif_dt(dt) cmd = [ "exiftool", "-overwrite_original", "-P", f"-DateTimeOriginal={stamp}", f"-CreateDate={stamp}", f"-ModifyDate={stamp}", filepath, ] def _run() -> subprocess.CompletedProcess: return subprocess.run( cmd, capture_output=True, text=True, timeout=EXIFTOOL_TIMEOUT_SECONDS, ) try: result = await asyncio.to_thread(_run) except subprocess.TimeoutExpired as exc: raise ExifWriteError(f"exiftool timed out writing {filepath}") from exc except FileNotFoundError as exc: raise ExifWriteError("exiftool binary not available") from exc if result.returncode != 0: msg = (result.stderr or result.stdout or "unknown error").strip() logger.warning("exiftool write failed for %s: %s", filepath, msg) raise ExifWriteError(msg)