Refactor photo metadata workflow
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from tools.photo_grouping import detect_photo_groups, infer_group_subseconds
|
||||
from tools.photo_planning import (
|
||||
UserChoices,
|
||||
build_write_plan,
|
||||
filename_timestamp_stem,
|
||||
infer_missing_timestamps,
|
||||
merge_record_groups,
|
||||
)
|
||||
from tools.photo_records import (
|
||||
MediaRecord,
|
||||
infer_video_timezone,
|
||||
)
|
||||
from tools.photo_settings import default_artist
|
||||
|
||||
|
||||
class PhotoMetadataLogicTests(unittest.TestCase):
|
||||
def choices(self, **values):
|
||||
base = dict(
|
||||
working_dir=Path("."),
|
||||
time_offset=None,
|
||||
fixed_timezone=None,
|
||||
fill_timezone_gaps=False,
|
||||
artist_action="leave",
|
||||
artist_value=None,
|
||||
rename_mode="adjust_replace",
|
||||
organize_files=False,
|
||||
group_min_size=2,
|
||||
process_pto_files=False,
|
||||
infer_missing_timestamps=True,
|
||||
)
|
||||
base.update(values)
|
||||
return UserChoices(**base)
|
||||
|
||||
def test_adjusted_filename_preserves_each_timestamp_interval(self) -> None:
|
||||
stem = "20260516_130011-20260516_130102_fused"
|
||||
result = filename_timestamp_stem(stem, datetime(2026, 6, 7, 14, 0, 0), "adjust_replace")
|
||||
self.assertEqual(result, "20260607_140000-20260607_140051_fused")
|
||||
|
||||
def test_uses_one_existing_artist_as_the_disabled_default(self) -> None:
|
||||
records = [
|
||||
MediaRecord(Path("a.jpg"), {"EXIF:Artist": "Anton"}, "image"),
|
||||
MediaRecord(Path("b.jpg"), {"XMP:Author": "Anton"}, "image"),
|
||||
]
|
||||
self.assertEqual(default_artist(records), "Anton")
|
||||
|
||||
def test_infers_missing_photo_time_from_filename_before_modify_date(self) -> None:
|
||||
record = MediaRecord(
|
||||
Path("20260607_140011.jpg"),
|
||||
{"EXIF:ModifyDate": "2020:01:01 00:00:00"},
|
||||
"image",
|
||||
)
|
||||
infer_missing_timestamps([record], True)
|
||||
self.assertEqual(record.original_time, datetime(2026, 6, 7, 14, 0, 11))
|
||||
self.assertEqual(record.inferred_timestamp_source, "filename")
|
||||
|
||||
def test_infers_missing_photo_time_from_modify_date(self) -> None:
|
||||
record = MediaRecord(
|
||||
Path("DSC0001.jpg"),
|
||||
{"EXIF:ModifyDate": "2026:06:07 14:00:11"},
|
||||
"image",
|
||||
)
|
||||
infer_missing_timestamps([record], True)
|
||||
self.assertEqual(record.original_time, datetime(2026, 6, 7, 14, 0, 11))
|
||||
self.assertEqual(record.inferred_timestamp_source, "ModifyDate")
|
||||
|
||||
def test_infers_video_timezone_from_filename_and_utc_end_time(self) -> None:
|
||||
record = MediaRecord(
|
||||
Path("20260704_161323.mp4"),
|
||||
{},
|
||||
"video",
|
||||
original_time=datetime(2026, 7, 4, 14, 13, 36, tzinfo=timezone.utc),
|
||||
duration_seconds=13,
|
||||
)
|
||||
self.assertEqual(infer_video_timezone(record), timezone(timedelta(hours=2)))
|
||||
|
||||
def test_existing_subseconds_prevent_burst_inference(self) -> None:
|
||||
records = [
|
||||
MediaRecord(Path("a.jpg"), {}, "image", adjusted_time=datetime(2026, 6, 7, 14, 0, 0), subsec=100),
|
||||
MediaRecord(Path("b.jpg"), {}, "image", adjusted_time=datetime(2026, 6, 7, 14, 0, 0)),
|
||||
]
|
||||
infer_group_subseconds([records])
|
||||
self.assertIsNone(records[1].inferred_subsec)
|
||||
|
||||
def test_burst_detection_stays_per_camera(self) -> None:
|
||||
timestamp = datetime(2026, 6, 7, 14, 0, 0)
|
||||
records = [
|
||||
MediaRecord(Path("a.jpg"), {}, "image", adjusted_time=timestamp, camera_key="A"),
|
||||
MediaRecord(Path("b.jpg"), {}, "image", adjusted_time=timestamp, camera_key="A"),
|
||||
MediaRecord(Path("c.jpg"), {}, "image", adjusted_time=timestamp, camera_key="B"),
|
||||
]
|
||||
self.assertEqual([[record.path.name for record in group] for group in detect_photo_groups(records)], [["a.jpg", "b.jpg"]])
|
||||
|
||||
def test_single_file_pto_group_is_preserved(self) -> None:
|
||||
record = MediaRecord(Path("a.jpg"), {}, "image")
|
||||
self.assertEqual(merge_record_groups([record], [[record]]), [[record]])
|
||||
|
||||
def test_shift_keeps_later_modify_date_but_clamps_an_earlier_one(self) -> None:
|
||||
original = datetime(2026, 6, 7, 14, 0, 0)
|
||||
record = MediaRecord(
|
||||
Path("photo.jpg"),
|
||||
{
|
||||
"EXIF:DateTimeOriginal": "2026:06:07 14:00:00",
|
||||
"EXIF:CreateDate": "2026:06:07 14:00:00",
|
||||
"EXIF:ModifyDate": "2026:06:07 13:00:00",
|
||||
},
|
||||
"image",
|
||||
original_time=original,
|
||||
adjusted_time=original + timedelta(hours=2),
|
||||
)
|
||||
writes = build_write_plan(record, self.choices(time_offset=timedelta(hours=2)))
|
||||
changed = {write.label: write.new_value for write in writes if write.will_write}
|
||||
self.assertEqual(changed["DateTimeOriginal"], "2026:06:07 16:00:00")
|
||||
self.assertEqual(changed["CreateDate"], "2026:06:07 16:00:00")
|
||||
self.assertEqual(changed["ModifyDate"], "2026:06:07 16:00:00")
|
||||
self.assertTrue(any("ModifyDate" in warning for warning in record.warnings))
|
||||
|
||||
def test_timezone_only_follows_capture_related_dates(self) -> None:
|
||||
captured = datetime(2026, 6, 7, 14, 0, 0)
|
||||
record = MediaRecord(
|
||||
Path("photo.jpg"),
|
||||
{
|
||||
"EXIF:DateTimeOriginal": "2026:06:07 14:00:00",
|
||||
"EXIF:CreateDate": "2026:06:07 14:00:00",
|
||||
"EXIF:ModifyDate": "2026:07:07 14:00:00",
|
||||
"EXIF:OffsetTimeOriginal": "+00:00",
|
||||
"EXIF:OffsetTimeDigitized": "+00:00",
|
||||
"EXIF:OffsetTime": "+00:00",
|
||||
},
|
||||
"image",
|
||||
original_time=captured,
|
||||
adjusted_time=captured,
|
||||
resolved_timezone=timezone(timedelta(hours=2)),
|
||||
)
|
||||
writes = build_write_plan(record, self.choices(fixed_timezone=record.resolved_timezone))
|
||||
changed = {write.arg for write in writes if write.will_write}
|
||||
self.assertIn("-EXIF:OffsetTimeOriginal=+02:00", changed)
|
||||
self.assertIn("-EXIF:OffsetTimeDigitized=+02:00", changed)
|
||||
self.assertNotIn("-EXIF:OffsetTime=+02:00", changed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user