The 0525.mov-style export from Synology Photos uses 2-digit years, which
the existing patterns ignored (all required \d{4}). Result: filename
gave no signal, suggestion fell through to the YYYY/MM folder layout and
snapped to day 15. The explicit HH-MM-SS half rules out random digit
triples, so we trust YY → 2000+YY for this specific shape and surface
the actual capture time, not noon.
240 lines
7.5 KiB
Python
240 lines
7.5 KiB
Python
"""
|
|
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]
|
|
|
|
|
|
# Synology Photos export: `YY-MM-DD HH-MM-SS NNNN.ext`. The explicit
|
|
# HH-MM-SS half is what makes the 2-digit year safe to trust — a random
|
|
# digit triple won't satisfy the hour/minute/second range checks below.
|
|
# YY is mapped to 2000+YY (this format is a recent export convention).
|
|
_SYNOLOGY_RE = re.compile(
|
|
r"(?<!\d)(\d{2})-(\d{2})-(\d{2})[\s_](\d{2})-(\d{2})-(\d{2})(?!\d)"
|
|
)
|
|
_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 = _SYNOLOGY_RE.search(input)
|
|
if m:
|
|
yy, mm, dd = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
hh, mi, ss = int(m.group(4)), int(m.group(5)), int(m.group(6))
|
|
if hh < 24 and mi < 60 and ss < 60:
|
|
d = _make_date(2000 + yy, mm, dd)
|
|
if d:
|
|
d = d.replace(hour=hh, minute=mi, second=ss)
|
|
return DateGuess(
|
|
date=d,
|
|
confidence="high",
|
|
matched=(
|
|
f"{m.group(1)}-{m.group(2)}-{m.group(3)} "
|
|
f"{m.group(4)}:{m.group(5)}:{m.group(6)}"
|
|
),
|
|
source=source,
|
|
)
|
|
|
|
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
|