Refactor photo metadata workflow

This commit is contained in:
ajp_anton
2026-08-24 16:34:20 +00:00
parent 100cad1cb5
commit eb5937a629
15 changed files with 2111 additions and 1727 deletions
+251
View File
@@ -0,0 +1,251 @@
"""Photo/video record types and ExifTool metadata decoding."""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from tools.filenames import naive_wall_time
from tools.media_metadata import first_tag, parse_exif_datetime
from tools.timezones import parse_timezone_offset
IMAGE_EXTS = {".jpg", ".jpeg", ".heic", ".arw"}
VIDEO_EXTS = {".mp4", ".mov", ".mts"}
SUPPORTED_EXTS = IMAGE_EXTS | VIDEO_EXTS
IMAGE_CAPTURE_TIME_TAGS = (
"Composite:SubSecDateTimeOriginal",
"Composite:SubSecCreateDate",
"EXIF:DateTimeOriginal",
"XMP:DateTimeOriginal",
"XMP:CreationDate",
"XMP:CreateDate",
"EXIF:CreateDate",
)
IMAGE_MODIFY_TIME_TAGS = ("Composite:SubSecModifyDate", "EXIF:ModifyDate", "XMP:ModifyDate")
IMAGE_OFFSET_TAGS = (
"EXIF:OffsetTimeOriginal",
"EXIF:OffsetTimeDigitized",
"EXIF:OffsetTime",
)
VIDEO_TIME_TAGS = (
"QuickTime:MediaCreateDate",
"QuickTime:TrackCreateDate",
"QuickTime:CreateDate",
"QuickTime:MediaModifyDate",
"QuickTime:TrackModifyDate",
"QuickTime:ModifyDate",
"XMP:DateTimeOriginal",
"XMP:CreateDate",
)
VIDEO_BEGIN_TIME_TAGS = ("H264:DateTimeOriginal",)
VIDEO_DURATION_TAGS = (
"QuickTime:Duration",
"Composite:Duration",
"File:Duration",
"M2TS:Duration",
)
VIDEO_TIMEZONE_TAGS = ("QuickTime:AndroidTimeZone", "QuickTime:Keys:AndroidTimeZone")
SEQUENCE_TAGS = (
"MakerNotes:SequenceNumber",
"MakerNotes:SequenceNumberOriginal",
"MakerNotes:ImageNumber",
"EXIF:ImageNumber",
"SequenceNumber",
)
FILENAME_TIMESTAMP_RE = re.compile(r"(?P<date>\d{8})[-_](?P<time>\d{6})")
@dataclass
class XmlSidecar:
path: Path
creation: datetime | None = None
duration_seconds: float | None = None
timezone_value: timezone | None = None
device: str | None = None
@dataclass
class TagWrite:
label: str
arg: str
current_value: str | None
new_value: str
will_write: bool
@dataclass
class MediaRecord:
path: Path
metadata: dict
kind: str
original_time: datetime | None = None
adjusted_time: datetime | None = None
timezone_value: timezone | None = None
resolved_timezone: timezone | None = None
inferred_timestamp_source: str | None = None
subsec: int | None = None
inferred_subsec: int | None = None
sequence: int | None = None
camera_key: str = ""
duration_seconds: float | None = None
video_time_is_beginning: bool = False
parsed_from_filename: bool = False
sidecar: XmlSidecar | None = None
new_stem: str | None = None
final_name: str | None = None
group_id: int | None = None
group_name: str | None = None
target_path: Path | None = None
target_sidecar: Path | None = None
rename_skip_reason: str | None = None
write_plan: list[TagWrite] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@property
def is_image(self) -> bool:
return self.kind == "image"
@property
def is_video(self) -> bool:
return self.kind == "video"
def parse_subsec(value: object) -> int | None:
match = re.search(r"\d+", str(value)) if value is not None else None
return int(match.group(0)[:3].ljust(3, "0")) if match else None
def parse_sequence(value: object) -> int | None:
match = re.search(r"\d+", str(value)) if value is not None else None
return int(match.group(0)) if match else None
def parse_duration_seconds(value: object) -> float | None:
if value is None:
return None
text = str(value).strip()
match = re.match(r"^([0-9]+(?:\.[0-9]+)?)\s*(?:s|sec|seconds)?$", text, re.I)
if match:
return float(match.group(1))
match = re.match(r"^(?:(?P<h>\d+):)?(?P<m>\d{1,2}):(?P<s>\d{1,2}(?:\.\d+)?)$", text)
if match:
return int(match.group("h") or 0) * 3600 + int(match.group("m")) * 60 + float(match.group("s"))
match = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*s", text, re.I)
return float(match.group(1)) if match else None
def parse_filename_timestamp(record: MediaRecord) -> datetime | None:
return parse_filename_timestamp_text(record.path.stem)
def parse_filename_timestamp_text(text: str) -> datetime | None:
match = FILENAME_TIMESTAMP_RE.search(text)
return parse_exif_datetime(match.group(0)) if match else None
def find_sidecar(video_path: Path) -> XmlSidecar | None:
expected = f"{video_path.stem}M01.XML"
for child in video_path.parent.iterdir():
if child.is_file() and child.name.lower() == expected.lower():
return parse_xml_sidecar(child)
return None
def parse_xml_sidecar(path: Path) -> XmlSidecar:
sidecar = XmlSidecar(path)
try:
root = ET.parse(path).getroot()
except ET.ParseError as exc:
sidecar.device = f"unreadable XML: {exc}"
return sidecar
duration = fps = None
for element in root.iter():
name = element.tag.rsplit("}", 1)[-1]
if name == "CreationDate" and (value := element.attrib.get("value")):
sidecar.creation = parse_exif_datetime(value)
if sidecar.creation and sidecar.creation.tzinfo:
sidecar.timezone_value = sidecar.creation.tzinfo
elif name == "Duration" and (value := element.attrib.get("value")):
duration = parse_duration_seconds(value)
elif name == "VideoFrame":
match = re.search(r"\d+(?:\.\d+)?", element.attrib.get("captureFps") or element.attrib.get("formatFps") or "")
fps = float(match.group(0)) if match else fps
elif name == "Device":
sidecar.device = " ".join(filter(None, (element.attrib.get("manufacturer"), element.attrib.get("modelName"))))
sidecar.duration_seconds = duration / fps if duration is not None and fps else duration
return sidecar
def camera_key(record: MediaRecord) -> str:
metadata = record.metadata
make = first_tag(metadata, ("EXIF:Make", "QuickTime:Make", "MakerNotes:Make", "H264:Make")) or ""
model = first_tag(metadata, ("EXIF:Model", "QuickTime:Model", "MakerNotes:Model", "H264:Model")) or ""
serial = first_tag(metadata, (
"EXIF:SerialNumber", "MakerNotes:SerialNumber", "MakerNotes:InternalSerialNumber", "Composite:SerialNumber",
))
return "|".join(str(part).strip() for part in (make, model, serial) if part) or f"dir:{record.path.parent.resolve()}"
def infer_video_timezone(record: MediaRecord) -> timezone | None:
if record.original_time is None or record.duration_seconds is None:
return None
filename_time = parse_filename_timestamp(record)
if filename_time is None:
return None
begin = record.original_time - timedelta(seconds=record.duration_seconds)
offset_seconds = (filename_time - naive_wall_time(begin.astimezone(timezone.utc))).total_seconds()
offset_minutes = round(offset_seconds / 60)
if abs(offset_seconds - offset_minutes * 60) > 2 or abs(offset_minutes) > 14 * 60:
return None
return timezone(timedelta(minutes=offset_minutes))
def build_records(paths: list[Path], metadata_items: list[dict]) -> list[MediaRecord]:
by_source = {Path(item["SourceFile"]).resolve(): item for item in metadata_items if item.get("SourceFile")}
records: list[MediaRecord] = []
for path in paths:
metadata = by_source.get(path.resolve(), {})
record = MediaRecord(path, metadata, "video" if path.suffix.lower() in VIDEO_EXTS else "image")
if record.is_image:
record.original_time = parse_exif_datetime(first_tag(metadata, IMAGE_CAPTURE_TIME_TAGS))
record.adjusted_time = record.original_time
offset = first_tag(metadata, IMAGE_OFFSET_TAGS)
if offset:
try:
record.timezone_value = parse_timezone_offset(str(offset))
except ValueError:
record.warnings.append(f"Invalid timezone offset metadata: {offset}")
record.subsec = parse_subsec(first_tag(metadata, (
"EXIF:SubSecTimeOriginal", "EXIF:SubSecTimeDigitized", "EXIF:SubSecTime",
)))
else:
begin = first_tag(metadata, VIDEO_BEGIN_TIME_TAGS)
if path.suffix.lower() == ".mts" and begin is not None:
record.original_time = parse_exif_datetime(begin, assume_utc=True)
record.video_time_is_beginning = True
else:
record.original_time = parse_exif_datetime(first_tag(metadata, VIDEO_TIME_TAGS), assume_utc=True)
record.adjusted_time = record.original_time
record.duration_seconds = parse_duration_seconds(first_tag(metadata, VIDEO_DURATION_TAGS))
offset = first_tag(metadata, VIDEO_TIMEZONE_TAGS)
if offset:
try:
record.timezone_value = parse_timezone_offset(str(offset))
except ValueError:
record.warnings.append(f"Invalid video timezone metadata: {offset}")
record.timezone_value = record.timezone_value or infer_video_timezone(record)
record.sidecar = find_sidecar(path)
if record.sidecar and record.duration_seconds is not None:
xml_duration = record.sidecar.duration_seconds
if xml_duration is not None and abs(xml_duration - record.duration_seconds) > 1:
record.warnings.append(f"XML duration {xml_duration:g}s differs from metadata duration {record.duration_seconds:g}s")
record.sequence = parse_sequence(first_tag(metadata, SEQUENCE_TAGS))
record.camera_key = camera_key(record)
records.append(record)
return records