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>
This commit is contained in:
2026-04-11 11:48:55 +02:00
parent 339e1be510
commit 30d03d8d4d
19 changed files with 1281 additions and 26 deletions

View File

@@ -0,0 +1,214 @@
"""
Folder/filename-based date guessing and "taken_at looks wrong" detection.
Direct Python port of `frontend/src/lib/guessDateFromPath.ts` — the logic
must stay in sync because the frontend renders the suggestion hint in the
info panel while the backend owns the `has_date_warning` flag that the
filter bar queries. Any heuristic change has to be applied to both files.
The guesser walks a filepath, tries the filename first as the source of
truth, then falls back to folder segments (deepest first) and multi-
segment layouts. Returns ``None`` when no recognisable date can be
extracted. `has_date_warning()` compares the guess to a stored `taken_at`
and reports whether the difference is large enough to flag.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Optional
Confidence = Literal["high", "medium", "low"]
Source = Literal["folder", "filename"]
@dataclass(frozen=True)
class DateGuess:
date: datetime
confidence: Confidence
matched: str
source: Source
_MIN_YEAR = 1970
# Bump the ceiling annually via `datetime.now()` rather than a literal so
# we don't ship a time bomb. `+1` allows near-future timestamps (cameras
# with a slightly advanced clock at year end) without opening the door to
# 4-digit serial numbers that happen to start with "30xx".
def _max_year() -> int:
return datetime.now().year + 1
def _valid_year(y: int) -> bool:
return _MIN_YEAR <= y <= _max_year()
def _make_date(y: int, m: int, d: int) -> Optional[datetime]:
if not _valid_year(y):
return None
if not (1 <= m <= 12):
return None
if not (1 <= d <= 31):
return None
try:
# Noon local so downstream day-bucketing is stable across timezone
# rounding. The frontend mirrors this.
return datetime(y, m, d, 12, 0, 0)
except ValueError:
return None
def _segments(filepath: str) -> list[str]:
return [s for s in re.split(r"[\\/]+", filepath) if s]
_COMPACT_RE = re.compile(r"(?<!\d)(\d{4})(\d{2})(\d{2})(?!\d)")
_DASHED_RE = re.compile(r"(?<!\d)(\d{4})[-_.](\d{1,2})[-_.](\d{1,2})(?!\d)")
_MONTH_RE = re.compile(r"(?<!\d)(\d{4})[-_.](\d{1,2})(?!\d)")
_YEAR_RE = re.compile(r"(?<!\d)(\d{4})(?!\d)")
_FOUR_DIGITS = re.compile(r"^\d{4}$")
_ONE_OR_TWO = re.compile(r"^\d{1,2}$")
def _guess_from_string(
input: str,
source: Source,
allow_year_only: bool,
) -> Optional[DateGuess]:
if not input:
return None
m = _COMPACT_RE.search(input)
if m:
d = _make_date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
if d:
return DateGuess(
date=d,
confidence="high",
matched=f"{m.group(1)}-{m.group(2)}-{m.group(3)}",
source=source,
)
m = _DASHED_RE.search(input)
if m:
d = _make_date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
if d:
return DateGuess(
date=d,
confidence="high",
matched=f"{m.group(1)}-{m.group(2)}-{m.group(3)}",
source=source,
)
m = _MONTH_RE.search(input)
if m:
d = _make_date(int(m.group(1)), int(m.group(2)), 15)
if d:
return DateGuess(
date=d,
confidence="medium",
matched=f"{m.group(1)}-{m.group(2)}",
source=source,
)
if allow_year_only:
m = _YEAR_RE.search(input)
if m:
d = _make_date(int(m.group(1)), 7, 1)
if d:
return DateGuess(
date=d,
confidence="low",
matched=m.group(1),
source=source,
)
return None
def _guess_from_folder_layout(folders: list[str]) -> Optional[DateGuess]:
# YYYY / MM / DD
for i in range(len(folders) - 2):
a, b, c = folders[i], folders[i + 1], folders[i + 2]
if _FOUR_DIGITS.match(a) and _ONE_OR_TWO.match(b) and _ONE_OR_TWO.match(c):
d = _make_date(int(a), int(b), int(c))
if d:
return DateGuess(
date=d,
confidence="high",
matched=f"{a}/{b}/{c}",
source="folder",
)
# YYYY / MM
for i in range(len(folders) - 1):
a, b = folders[i], folders[i + 1]
if _FOUR_DIGITS.match(a) and _ONE_OR_TWO.match(b):
d = _make_date(int(a), int(b), 15)
if d:
return DateGuess(
date=d,
confidence="medium",
matched=f"{a}/{b}",
source="folder",
)
return None
_CONFIDENCE_RANK: dict[Confidence, int] = {"high": 3, "medium": 2, "low": 1}
def guess_date_from_path(filepath: str) -> Optional[DateGuess]:
"""Filename wins when it has any viable match; otherwise walk folder
segments deepest-first and pick the strongest hit."""
if not filepath:
return None
segs = _segments(filepath)
if not segs:
return None
filename = segs[-1]
folders = segs[:-1]
from_filename = _guess_from_string(filename, "filename", allow_year_only=False)
if from_filename:
return from_filename
best: Optional[DateGuess] = None
for seg in reversed(folders):
hit = _guess_from_string(seg, "folder", allow_year_only=True)
if not hit:
continue
if not best or _CONFIDENCE_RANK[hit.confidence] > _CONFIDENCE_RANK[best.confidence]:
best = hit
if hit.confidence == "high":
break
from_layout = _guess_from_folder_layout(folders)
if from_layout and (
not best or _CONFIDENCE_RANK[from_layout.confidence] > _CONFIDENCE_RANK[best.confidence]
):
best = from_layout
return best
_ONE_DAY = 24 * 60 * 60
def has_date_warning(filepath: str, taken_at: Optional[datetime]) -> bool:
"""True when the path-based guess disagrees with ``taken_at`` by more
than 24h, or when ``taken_at`` is missing and the path would supply
one. This is the authoritative flag stored on `photos.has_date_warning`
and queried by the timeline filter."""
guess = guess_date_from_path(filepath)
if not guess:
return False
if taken_at is None:
return True
try:
diff = abs((taken_at - guess.date).total_seconds())
except (TypeError, ValueError):
return False
return diff > _ONE_DAY

View File

@@ -0,0 +1,72 @@
"""
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)

View File

@@ -15,6 +15,7 @@ from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import Photo
from app.services.date_guess import has_date_warning
logger = logging.getLogger(__name__)
@@ -238,6 +239,14 @@ async def _extract_metadata_async(photo_id: str):
photo.taken_at = taken_at
photo.taken_at_source = 'exif'
break
# Re-run the path-vs-date heuristic now that we know
# whether EXIF provided a real capture date. A true EXIF
# date that matches the folder clears the warning the
# scanner set during the filesystem-mtime pass.
photo.has_date_warning = has_date_warning(
photo.filepath, photo.taken_at
)
# Extract dimensions if not already set
if not photo.width: