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
+33 -1
View File
@@ -5,7 +5,9 @@ import unittest
from pathlib import Path
from unittest.mock import patch
from tools.exiftool import run_exiftool_command
from tools.exiftool import run_exiftool_command, run_exiftool_write
from tools.media_metadata import parse_exif_datetime
from tools.metadata_copy import copy_meaningful_metadata
class ExiftoolInvocationTests(unittest.TestCase):
@@ -34,6 +36,36 @@ class ExiftoolInvocationTests(unittest.TestCase):
)
self.assertEqual(captured["contents"], "-j\nC:\\Photos\\M\u00e4rchen.jpg\n")
def test_write_captures_success_output(self) -> None:
with patch(
"tools.exiftool.subprocess.run",
return_value=subprocess.CompletedProcess([], 0, "1 image files updated\n", ""),
) as run:
run_exiftool_write("exiftool", Path("photo.jpg"), ["-Artist=Test"])
self.assertTrue(run.call_args.kwargs["capture_output"])
self.assertTrue(run.call_args.kwargs["text"])
def test_write_includes_exiftool_failure_output(self) -> None:
with patch(
"tools.exiftool.subprocess.run",
return_value=subprocess.CompletedProcess([], 1, "", "Error renaming temporary file"),
):
with self.assertRaisesRegex(RuntimeError, "Error renaming temporary file"):
run_exiftool_write("exiftool", Path("photo.jpg"), ["-Artist=Test"])
def test_metadata_copy_uses_exiftool_for_photos(self) -> None:
with patch("tools.metadata_copy.run_exiftool_command") as run:
copy_meaningful_metadata("exiftool", Path("source.jpg"), Path("target.jpg"))
args = run.call_args.args[1]
self.assertEqual(args[:4], ["-overwrite_original", "-TagsFromFile", "source.jpg", "-all:all"])
self.assertEqual(args[-1], "target.jpg")
def test_metadata_datetime_can_assume_utc(self) -> None:
parsed = parse_exif_datetime("2026:07:04 14:13:36", assume_utc=True)
self.assertEqual(str(parsed.tzinfo), "UTC")
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -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()
+67 -1
View File
@@ -87,7 +87,7 @@ from tools.video_probe import (
)
from tools.video_reporting import _format_probe_timestamp, _format_vfr_timing
from tools.video_reporting import _format_source_span, format_validation_summary_lines
from tools.console import PipelineProgressView, ProgressView, _progress_bar
from tools.console import PipelineProgressView, ProgressView, _progress_bar, stream_is_interactive
from tools.video_encode_output import _update_renderer_progress
from tools.video_timeline import OutputFrameTiming, OutputTimeline, build_output_timeline
from video_encode import (
@@ -681,6 +681,72 @@ class VideoTimelineTests(unittest.TestCase):
"[## 50.0% ###------------]",
)
def test_progress_can_weight_percent_and_eta_by_work(self) -> None:
progress = ProgressView(4, "Applying changes", total_work=10, stream=StringIO())
line = progress.update_line(1, completed_work=5)
self.assertIn("1/4", line)
self.assertIn("50.0%", line)
def test_windows_console_progress_does_not_require_stdout_isatty(self) -> None:
class ConsoleBuffer(StringIO):
def isatty(self) -> bool:
return False
output = ConsoleBuffer()
with (
patch("tools.console.os.name", "nt"),
patch("tools.console.sys.stdout", output),
patch("tools.console._windows_console_output", return_value=True),
):
self.assertTrue(stream_is_interactive(output))
progress = ProgressView(3, "Applying changes", stream=output)
progress.update(1)
progress.update(2)
progress.finish(keep=True)
self.assertEqual(output.getvalue().count("\n"), 1)
def test_single_line_ansi_progress_never_moves_up_zero_lines(self) -> None:
class TtyBuffer(StringIO):
def isatty(self) -> bool:
return True
output = TtyBuffer()
with patch("tools.console.supports_ansi", return_value=True):
progress = ProgressView(3, "Reading metadata", stream=output)
progress.update(1)
progress.update(2)
progress.finish(keep=True)
self.assertNotIn("\x1b[0F", output.getvalue())
def test_progress_redraws_a_wrapped_multi_line_block(self) -> None:
class TtyBuffer(StringIO):
def isatty(self) -> bool:
return True
output = TtyBuffer()
with (
patch("tools.console.supports_ansi", return_value=True),
patch.dict("os.environ", {"COLUMNS": "30"}),
):
progress = ProgressView(3, "Progress", stream=output)
progress.write_lines(
[
"Applying changes",
" Results: succeeded 1 failed 0 metadata 1 no-op 0",
" File ops: rename 0 move 0 rename+move 0",
]
)
progress.write_lines(["Applying changes", " Results: succeeded 2 failed 0 metadata 2 no-op 0"])
progress.finish(keep=True)
rendered = output.getvalue()
self.assertIn("\x1b[2K", rendered)
self.assertIn("\x1b[", rendered)
def test_encoding_progress_matches_persistent_validation_style(self) -> None:
class TtyBuffer(StringIO):
def isatty(self) -> bool: