Files
mule-image/backend/app/services/exif_writer.py
dtoro 30d03d8d4d feat: editable taken_at + folder-based date repair and filter
Lets operators fix corrupted capture dates at scale. Adds an editable
Date Taken field with a folder/filename-derived suggestion hint, a bulk
Date Taken section in the multi-select sidebar that either applies one
date to the whole selection or infers a per-photo date from each path,
a warning badge on thumbnails whose stored date disagrees with the
path, and a "Date issues" filter pill so suspicious photos can be
surfaced and fixed as a group. Edits are written back to EXIF on disk
so rescans don't clobber the fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:48:55 +02:00

73 lines
2.3 KiB
Python

"""
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
``<name>_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)