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>
73 lines
2.3 KiB
Python
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)
|