From d3c63f0e773db82069a565596800b13b9dfce565 Mon Sep 17 00:00:00 2001
From: Reid 'arrdem' McKenzie <me@arrdem.com>
Date: Mon, 6 Sep 2021 21:15:03 -0600
Subject: [PATCH] The archiver tools

---
 projects/archiver/BUILD         |  12 +
 projects/archiver/hash_copy.py  |  70 ++++
 projects/archiver/org_photos.py | 645 ++++++++++++++++++++++++++++++++
 tools/python/requirements.txt   |   2 +
 4 files changed, 729 insertions(+)
 create mode 100644 projects/archiver/BUILD
 create mode 100644 projects/archiver/hash_copy.py
 create mode 100644 projects/archiver/org_photos.py

diff --git a/projects/archiver/BUILD b/projects/archiver/BUILD
new file mode 100644
index 0000000..fe7ef5c
--- /dev/null
+++ b/projects/archiver/BUILD
@@ -0,0 +1,12 @@
+zapp_binary(
+    name = "hash_copy",
+    main = "hash_copy.py",
+)
+
+zapp_binary(
+    name = "org_photos",
+    main = "org_photos.py",
+    deps = [
+        py_requirement("ExifRead"),
+    ]
+)
diff --git a/projects/archiver/hash_copy.py b/projects/archiver/hash_copy.py
new file mode 100644
index 0000000..c4252e9
--- /dev/null
+++ b/projects/archiver/hash_copy.py
@@ -0,0 +1,70 @@
+"""
+A tree deduplicator and archiver tool.
+"""
+
+import argparse
+from pathlib import Path
+from hashlib import sha256
+from shutil import copyfile
+
+
+parser = argparse.ArgumentParser()
+parser.add_argument("from_dir", type=Path)
+parser.add_argument("to_dir", type=Path)
+
+
+def checksum(p: Path, sum=sha256) -> str:
+    """Compute block-wise checksums of a file.
+
+    Inspired by the Dropbox content-hashing interface -
+
+    https://www.dropbox.com/developers/reference/content-hash
+
+    """
+
+    def iter_chunks(fp):
+        yield from iter(lambda: fp.read(4096), b"")
+
+    def _helper():
+        with open(p, "rb") as fp:
+            for chunk in iter_chunks(fp):
+                digest = sum()
+                digest.update(chunk)
+                yield digest.hexdigest()
+
+    return list(_helper())
+
+
+def main():
+    opts, args = parser.parse_known_args()
+
+    for abs_src_path in opts.from_dir.glob("**/*"):
+        rel_src_path = abs_src_path.relative_to(opts.from_dir)
+        abs_dest_path = opts.to_dir / rel_src_path
+
+        if abs_src_path.is_dir():
+            print("dir", abs_src_path, "->", abs_dest_path)
+            abs_dest_path.mkdir(exist_ok=True)
+
+        elif abs_src_path.is_file():
+            print("file", abs_src_path, "->", abs_dest_path)
+
+            if not abs_dest_path.exists():
+                copyfile(abs_src_path, abs_dest_path)
+
+            else:
+                # If you trust mtime, this can go a lot faster
+                trust_mtime = False
+
+                if trust_mtime and abs_dest_path.stat().st_mtime < abs_src_path.stat().st_mtime:
+                     pass
+
+                elif (src_checksum := checksum(abs_src_path)) != (dest_checksum := checksum(abs_dest_path)):
+                    print(f"file conflict (src {src_checksum}, dest {dest_checksum}), correcting...")
+                    copyfile(abs_src_path, abs_dest_path)
+
+            abs_src_path.unlink()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/projects/archiver/org_photos.py b/projects/archiver/org_photos.py
new file mode 100644
index 0000000..349b4b1
--- /dev/null
+++ b/projects/archiver/org_photos.py
@@ -0,0 +1,645 @@
+"""Organize (and annotate) photos predictably.
+
+This script is designed to eat a Dropbox style "camera uploads" directory containing many years of photos with various
+metadata (or data only in the file name) and stomp that all down to something consistent by doing EXIF parsing and
+falling back to filename parsing if need be.
+
+Note that this script does NOT do EXIF rewriting, although it probably should.
+
+Generates an output tree of the format -
+  {year}/v1_{iso_date}_{camera_make}_{camera_model}_{device_fingerprint}.{ext}
+
+The idea being that chronological-ish order is the default way users will want to view (or process) photos.
+Note that this means if you do interleaved shooting between devices, it should Just Work (TM).
+
+Inspired by https://github.com/herval/org_photos/blob/main/org_photos.rb
+
+"""
+
+import argparse
+import sys
+from pathlib import Path
+from hashlib import sha256, sha512
+from datetime import date
+from datetime import datetime
+from shutil import copy2 as copyfile
+import re
+import typing as t
+
+# FIXME: use piexif, which supports writeback not exifread.
+import exifread
+from exifread.classes import IfdTag
+
+
+parser = argparse.ArgumentParser()
+parser.add_argument("src_dir", type=Path)
+parser.add_argument("dest_dir", type=Path)
+
+
+MODIFIED_ISO_DATE = '%Y:%m:%dT%H:%M:%SF%f'
+
+
+def take(n, iter):
+    """Take the first N items lazily off of an iterable."""
+
+    for _ in range(0, n):
+        try:
+            yield next(iter)
+        except StopIteration:
+            break
+
+
+def exif_tags(p: Path) -> object:
+    """Return the EXIF tags on an image."""
+    with open(p, 'rb') as fp:
+        return exifread.process_file(fp)
+
+# EXIF tags dataset (exifread edition) -
+#
+# ---
+# - 'EXIF ApertureValue'
+# - 'EXIF BodySerialNumber'
+# - 'EXIF BrightnessValue'
+# - 'EXIF CVAPattern'
+# - 'EXIF CameraOwnerName'
+# - 'EXIF ColorSpace'
+# - 'EXIF ComponentsConfiguration'
+# - 'EXIF CompressedBitsPerPixel'
+# - 'EXIF Contrast'
+# - 'EXIF CustomRendered'
+# - 'EXIF DateTimeDigitized'
+# - 'EXIF DateTimeOriginal'
+# - 'EXIF DeviceSettingDescription'
+# - 'EXIF DigitalZoomRatio'
+# - 'EXIF ExifImageLength'
+# - 'EXIF ExifImageWidth'
+# - 'EXIF ExifVersion'
+# - 'EXIF ExposureBiasValue'
+# - 'EXIF ExposureIndex'
+# - 'EXIF ExposureMode'
+# - 'EXIF ExposureProgram'
+# - 'EXIF ExposureTime'
+# - 'EXIF FNumber'
+# - 'EXIF FileSource'
+# - 'EXIF Flash'
+# - 'EXIF FlashEnergy'
+# - 'EXIF FlashPixVersion'
+# - 'EXIF FocalLength'
+# - 'EXIF FocalLengthIn35mmFilm'
+# - 'EXIF FocalPlaneResolutionUnit'
+# - 'EXIF FocalPlaneXResolution'
+# - 'EXIF FocalPlaneYResolution'
+# - 'EXIF GainControl'
+# - 'EXIF ISOSpeedRatings'
+# - 'EXIF ImageUniqueID'
+# - 'EXIF InteroperabilityOffset'
+# - 'EXIF JPEGInterchangeFormat'
+# - 'EXIF JPEGInterchangeFormatLength'
+# - 'EXIF LensMake'
+# - 'EXIF LensModel'
+# - 'EXIF LensSerialNumber'
+# - 'EXIF LensSpecification'
+# - 'EXIF LightSource'
+# - 'EXIF MakerNote'
+# - 'EXIF MaxApertureValue'
+# - 'EXIF MeteringMode'
+# - 'EXIF OffsetSchema'
+# - 'EXIF OffsetTime'
+# - 'EXIF OffsetTimeDigitized'
+# - 'EXIF OffsetTimeOriginal'
+# - 'EXIF Padding'
+# - 'EXIF RecommendedExposureIndex'
+# - 'EXIF Saturation'
+# - 'EXIF SceneCaptureType'
+# - 'EXIF SceneType'
+# - 'EXIF SensingMethod'
+# - 'EXIF SensitivityType'
+# - 'EXIF Sharpness'
+# - 'EXIF ShutterSpeedValue'
+# - 'EXIF SubSecTime'
+# - 'EXIF SubSecTimeDigitized'
+# - 'EXIF SubSecTimeOriginal'
+# - 'EXIF SubjectArea'
+# - 'EXIF SubjectDistance'
+# - 'EXIF SubjectDistanceRange'
+# - 'EXIF UserComment'
+# - 'EXIF WhiteBalance'
+# - 'GPS GPSAltitude'
+# - 'GPS GPSAltitudeRef'
+# - 'GPS GPSDOP'
+# - 'GPS GPSDate'
+# - 'GPS GPSImgDirection'
+# - 'GPS GPSImgDirectionRef'
+# - 'GPS GPSLatitude'
+# - 'GPS GPSLatitudeRef'
+# - 'GPS GPSLongitude'
+# - 'GPS GPSLongitudeRef'
+# - 'GPS GPSMapDatum'
+# - 'GPS GPSMeasureMode'
+# - 'GPS GPSProcessingMethod'
+# - 'GPS GPSTimeStamp'
+# - 'GPS GPSVersionID'
+# - 'GPS Tag 0xEA1C'
+# - 'Image Artist'
+# - 'Image BitsPerSample'
+# - 'Image Copyright'
+# - 'Image DateTime'
+# - 'Image DateTimeDigitized'
+# - 'Image ExifOffset'
+# - 'Image ExposureMode'
+# - 'Image ExposureProgram'
+# - 'Image ExposureTime'
+# - 'Image FNumber'
+# - 'Image Flash'
+# - 'Image FocalLength'
+# - 'Image GPSInfo'
+# - 'Image ISOSpeedRatings'
+# - 'Image ImageDescription'
+# - 'Image ImageLength'
+# - 'Image ImageWidth'
+# - 'Image JPEGInterchangeFormat'
+# - 'Image JPEGInterchangeFormatLength'
+# - 'Image LightSource'
+# - 'Image Make'
+# - 'Image MeteringMode'
+# - 'Image Model'
+# - 'Image Orientation'
+# - 'Image Padding'
+# - 'Image PhotometricInterpretation'
+# - 'Image PrintIM'
+# - 'Image ResolutionUnit'
+# - 'Image SamplesPerPixel'
+# - 'Image Software'
+# - 'Image UserComment'
+# - 'Image WhiteBalance'
+# - 'Image XPComment'
+# - 'Image XPKeywords'
+# - 'Image XPTitle'
+# - 'Image XResolution'
+# - 'Image YCbCrPositioning'
+# - 'Image YResolution'
+# - 'Interoperability InteroperabilityIndex'
+# - 'Interoperability InteroperabilityVersion'
+# - 'JPEGThumbnail'
+# - 'MakerNote AEBracketCompensationApplied'
+# - 'MakerNote AESetting'
+# - 'MakerNote AFAreaMode'
+# - 'MakerNote AFInfo2'
+# - 'MakerNote AFPointSelected'
+# - 'MakerNote AFPointUsed'
+# - 'MakerNote ActiveDLighting'
+# - 'MakerNote AspectInfo'
+# - 'MakerNote AutoBracketRelease'
+# - 'MakerNote AutoFlashMode'
+# - 'MakerNote BracketMode'
+# - 'MakerNote BracketShotNumber'
+# - 'MakerNote BracketValue'
+# - 'MakerNote BracketingMode'
+# - 'MakerNote CanonImageWidth'
+# - 'MakerNote ColorBalance'
+# - 'MakerNote ColorSpace'
+# - 'MakerNote ContinuousDriveMode'
+# - 'MakerNote Contrast'
+# - 'MakerNote CropHiSpeed'
+# - 'MakerNote CropInfo'
+# - 'MakerNote DigitalVariProgram'
+# - 'MakerNote DigitalZoom'
+# - 'MakerNote DustRemovalData'
+# - 'MakerNote EasyShootingMode'
+# - 'MakerNote ExposureDifference'
+# - 'MakerNote ExposureMode'
+# - 'MakerNote ExposureTuning'
+# - 'MakerNote ExternalFlashExposureComp'
+# - 'MakerNote FileInfo'
+# - 'MakerNote FileNumber'
+# - 'MakerNote FilterEffect'
+# - 'MakerNote FirmwareVersion'
+# - 'MakerNote FlashActivity'
+# - 'MakerNote FlashBias'
+# - 'MakerNote FlashBracketCompensationApplied'
+# - 'MakerNote FlashCompensation'
+# - 'MakerNote FlashDetails'
+# - 'MakerNote FlashExposureLock'
+# - 'MakerNote FlashInfo'
+# - 'MakerNote FlashMode'
+# - 'MakerNote FlashSetting'
+# - 'MakerNote FocalLength'
+# - 'MakerNote FocalType'
+# - 'MakerNote FocalUnitsPerMM'
+# - 'MakerNote FocusMode'
+# - 'MakerNote FocusType'
+# - 'MakerNote HDRImageType'
+# - 'MakerNote HighISONoiseReduction'
+# - 'MakerNote ISO'
+# - 'MakerNote ISOInfo'
+# - 'MakerNote ISOSetting'
+# - 'MakerNote ISOSpeedRequested'
+# - 'MakerNote ImageDataSize'
+# - 'MakerNote ImageSize'
+# - 'MakerNote ImageStabilization'
+# - 'MakerNote ImageType'
+# - 'MakerNote InternalSerialNumber'
+# - 'MakerNote LensData'
+# - 'MakerNote LensFStops'
+# - 'MakerNote LensMinMaxFocalMaxAperture'
+# - 'MakerNote LensModel'
+# - 'MakerNote LensType'
+# - 'MakerNote LiveViewShooting'
+# - 'MakerNote LongExposureNoiseReduction2'
+# - 'MakerNote LongFocalLengthOfLensInFocalUnits'
+# - 'MakerNote MacroMagnification'
+# - 'MakerNote Macromode'
+# - 'MakerNote MakernoteVersion'
+# - 'MakerNote ManualFlashOutput'
+# - 'MakerNote MeteringMode'
+# - 'MakerNote ModelID'
+# - 'MakerNote MultiExposure'
+# - 'MakerNote NikonPreview'
+# - 'MakerNote NoiseReduction'
+# - 'MakerNote NumAFPoints'
+# - 'MakerNote OwnerName'
+# - 'MakerNote PhotoCornerCoordinates'
+# - 'MakerNote PictureControl'
+# - 'MakerNote PowerUpTime'
+# - 'MakerNote ProgramShift'
+# - 'MakerNote Quality'
+# - 'MakerNote RawJpgQuality'
+# - 'MakerNote RawJpgSize'
+# - 'MakerNote RecordMode'
+# - 'MakerNote RetouchHistory'
+# - 'MakerNote Saturation'
+# - 'MakerNote SelfTimer'
+# - 'MakerNote SequenceNumber'
+# - 'MakerNote SerialNumber'
+# - 'MakerNote Sharpness'
+# - 'MakerNote ShortFocalLengthOfLensInFocalUnits'
+# - 'MakerNote ShotInfo'
+# - 'MakerNote SlowShutter'
+# - 'MakerNote SpotMeteringMode'
+# - 'MakerNote SubjectDistance'
+# - 'MakerNote Tag 0x0001'
+# - 'MakerNote Tag 0x0002'
+# - 'MakerNote Tag 0x0003'
+# - 'MakerNote Tag 0x0004'
+# - 'MakerNote Tag 0x0005'
+# - 'MakerNote Tag 0x0006'
+# - 'MakerNote Tag 0x0007'
+# - 'MakerNote Tag 0x0008'
+# - 'MakerNote Tag 0x0009'
+# - 'MakerNote Tag 0x000E'
+# - 'MakerNote Tag 0x0014'
+# - 'MakerNote Tag 0x0015'
+# - 'MakerNote Tag 0x0019'
+# - 'MakerNote Tag 0x002B'
+# - 'MakerNote Tag 0x002C'
+# - 'MakerNote Tag 0x002D'
+# - 'MakerNote Tag 0x0083'
+# - 'MakerNote Tag 0x0099'
+# - 'MakerNote Tag 0x009D'
+# - 'MakerNote Tag 0x00A0'
+# - 'MakerNote Tag 0x00A3'
+# - 'MakerNote Tag 0x00AA'
+# - 'MakerNote Tag 0x00BB'
+# - 'MakerNote Tag 0x00D0'
+# - 'MakerNote Tag 0x00E0'
+# - 'MakerNote Tag 0x4001'
+# - 'MakerNote Tag 0x4008'
+# - 'MakerNote Tag 0x4009'
+# - 'MakerNote Tag 0x4010'
+# - 'MakerNote Tag 0x4011'
+# - 'MakerNote Tag 0x4012'
+# - 'MakerNote Tag 0x4015'
+# - 'MakerNote Tag 0x4016'
+# - 'MakerNote Tag 0x4017'
+# - 'MakerNote Tag 0x4018'
+# - 'MakerNote Tag 0x4019'
+# - 'MakerNote Tag 0x4020'
+# - 'MakerNote ThumbnailImageValidArea'
+# - 'MakerNote ToningEffect'
+# - 'MakerNote TotalShutterReleases'
+# - 'MakerNote Unknown'
+# - 'MakerNote VRInfo'
+# - 'MakerNote ValidAFPoints'
+# - 'MakerNote WBBracketMode'
+# - 'MakerNote WBBracketValueAB'
+# - 'MakerNote WBBracketValueGM'
+# - 'MakerNote WhiteBalance'
+# - 'MakerNote WhiteBalanceBias'
+# - 'MakerNote WhiteBalanceRBCoeff'
+# - 'MakerNote Whitebalance'
+# - 'MakerNote WorldTime'
+# - 'Thumbnail Compression'
+# - 'Thumbnail DateTime'
+# - 'Thumbnail ImageDescription'
+# - 'Thumbnail ImageLength'
+# - 'Thumbnail ImageWidth'
+# - 'Thumbnail JPEGInterchangeFormat'
+# - 'Thumbnail JPEGInterchangeFormatLength'
+# - 'Thumbnail Make'
+# - 'Thumbnail Model'
+# - 'Thumbnail Orientation'
+# - 'Thumbnail ResolutionUnit'
+# - 'Thumbnail Software'
+# - 'Thumbnail XResolution'
+# - 'Thumbnail YCbCrPositioning'
+# - 'Thumbnail YResolution'
+
+
+def checksum(p: Path, sum=sha256) -> str:
+    """Compute a chunked checksum of a file.
+
+    Does not produce individual block checksums.
+    """
+
+    def iter_blocks(fp):
+        yield from iter(lambda: fp.read(4096), b"")
+
+    with open(p, "rb") as fp:
+        digest = sum()
+        for chunk in iter_blocks(fp):
+            digest.update(chunk)
+        return digest.hexdigest()
+
+
+def checksum_str(iter, sum=sha256):
+    """Compute the checksum of a bunch of stuff from an iterable."""
+
+    sum = sum()
+    for i in iter:
+        sum.update(b";")  # Merkle tree salting.
+        if isinstance(i, str):
+            i = str.encode(i, "utf-8")
+        try:
+            sum.update(i)
+        except Exception as e:
+            print(i, type(i))
+            raise e
+
+    return sum.hexdigest()
+
+
+def sanitize(s: str) -> str:
+    """Something like b64encode; sanitize a string to a path-friendly version."""
+
+    to_del = [" ", ";", ":", "_", "-", "/", "\\", "."]
+    s = s.lower()
+    for c in to_del:
+        s = s.replace(c, "")
+    return s
+
+
+def safe_strptime(date, format):
+    try:
+        return datetime.strptime(date, format)
+    except:
+        return None
+
+
+def date_from_name(p: Path):
+    """Try to munge a datestamp out of a path."""
+
+    fname = ".".join(p.name.split(".")[:-1])
+
+    # Discard common prefixes
+    fname = fname.replace("IMG_", "")
+    fname = fname.replace("PXL_", "")
+    fname = fname.replace("VID_", "")
+
+    if fname.startswith("v1_"):
+        # The date segment of the v1 file name
+        fname = fname.split("_")[1]
+
+    # Guess if we have a v0 file name, which neglected the prefix
+    elif fname.count("_") == 3:
+        # A bug
+        fname = fname.split("_")[-1].replace("rc", "")[:25]
+
+    # We have some other garbage, make our date parsing life easier.
+    else:
+        # A couple of date formats use _ as field separators, consistently choice " " instead so that we can write fewer
+        # date patterns and be more correct.
+        fname = fname.replace("_", " ")
+        fname = re.sub(r"(-\d+)(-\d+)*$", r"\1", fname)  # deal with -1-2 etc. crap from Dropbox
+
+    # Try to guess the date
+    # File date formats:
+    for fmt in [
+            # Our date format
+            MODIFIED_ISO_DATE,
+
+            # A bug
+            # 2014:08:21T19:4640F1408672000
+            # 2015:12:14T23:0933F1450159773
+            '%Y:%m:%dT%H:%M%SF%f',
+
+            # 2020-12-21 17.15.09.0
+            "%Y-%m-%d %H.%M.%S.%f",
+
+            # 2020-12-21 17.15.09
+            "%Y-%m-%d %H.%M.%S",
+
+            # 2019-02-09 12.45.32-6
+            # 2019-01-13 13.43.45-16
+            "%Y-%m-%d %H.%M.%S-%f",
+
+            # Note the _1 or such may not be millis, but we assume it is.
+            # 20171113_130826_1
+            # 20171113 130826 1
+            "%Y%m%d %H%M%S %f",
+
+            # 20180404_114639
+            # 20180404 114639
+            "%Y%m%d %H%M%S",
+
+            # 2017-11-05_15:15:55
+            # 2017-11-05 15:15:55
+            "%Y-%m-%d %H:%M:%S",
+
+            # 20210417_220753284
+            # 20210417 220753284
+            # 20210304 204755545
+            "%Y%m%d %h%m%s%f",
+    ]:
+        try:
+            return datetime.strptime(fname, fmt)
+        except:
+            continue
+    else:
+        print(f"Warning: Unable to infer datetime from {fname!r}", file=sys.stderr)
+
+
+
+def normalize_ext(p: Path):
+    renaming = {
+        ".jpg": ".jpeg",
+        ".jpeg": ".jpeg",
+        ".png": ".png",
+        ".mov": ".mov",
+        ".gif": ".gif",
+        ".mp4": ".mp4",
+        ".m4a": ".m4a",
+        ".oga": ".oga",  # How the hell do I have ogg files kicking around
+    }
+    exts = [e.lower() for e in p.suffixes]
+    # Guess an ext out of potentially many, allowing only for folding of effective dupes
+    exts = set(renaming[e] for e in exts if e in renaming)
+    assert len(exts) == 1
+    return list(exts)[0]
+
+
+class ImgInfo(t.NamedTuple):
+    file_path: Path
+    tags: dict
+
+    camera_make: str
+    camera_model: str
+    camera_sn: str
+
+    lens_make: str
+    lens_model: str
+    lens_sn: str
+
+    software: str
+
+    date: datetime
+
+    # A dirty bit, indicating whether the info needs to be written back
+    dirty: bool = False
+
+    shasum_prefix: int = 9
+
+    def device_fingerprint(self):
+        return checksum_str([
+            self.camera_make,
+            self.camera_model,
+            self.camera_sn,
+            self.lens_make,
+            self.lens_model,
+            self.lens_sn,
+            self.software,
+        ])[:self.shasum_prefix]
+
+    def file_fingerprint(self):
+        return self.file_sha256sum()[:self.shasum_prefix]
+
+    def file_sha256sum(self):
+        return checksum(self.file_path, sha256)
+
+    def file_sha512sum(self):
+        return checksum(self.file_path, sha512)
+
+
+def img_info(p: Path) -> ImgInfo:
+    """Figure out everything we know from the image info."""
+
+    tags = exif_tags(p)
+
+    def get_tag(tag, default=None):
+        if (v := tags.get(tag)):
+            if isinstance(v.values, list):
+                return v.values[0]
+            elif isinstance(v.values, str):
+                return v.values
+            else:
+                raise ValueError(f"Don't know how to simplify {v!r}")
+        else:
+            return default
+
+    ## Camera data
+    camera_make = get_tag("Image Make", "Unknown")
+    camera_model = get_tag("Image Model", "Unknown")
+    camera_sn = get_tag("MakerNote SerialNumber", "Unknown")
+    lens_make = get_tag('EXIF LensMake', "Unknown")
+    lens_model = get_tag('EXIF LensModel', "Unknown")
+    lens_sn = get_tag('EXIF LensSerialNumber', "Unknown")
+    software = get_tag("Image Software", "Unknown")
+    dirty = False
+
+    # Fixup magic for "correcting" metadata where possible
+    # FIXME: This could be a postprocessing pass
+    if camera_make == "Unknown" and p.name.lower().startswith("dji"):
+        # Magic constants from found examples
+        camera_make = "DJI"
+        camera_model = "FC220"
+        software = "v02.09.514"
+        dirty |= True
+
+    elif camera_make == "Unknown" and p.name.startswith("PXL_"):
+        camera_make = "Google"
+        camera_model = "Pixel"
+        dirty |= True
+
+    ## Timestamp data
+    stat = p.stat()
+
+    # 2019:03:31 15:59:26
+    date = get_tag("Image DateTime") or get_tag("EXIF DateTimeOriginal") or get_tag("EXIF DateTimeDigitized")
+    if date and (date := safe_strptime(date, "%Y:%m:%d %H:%M:%S")):
+        pass
+    elif (date := date_from_name(p)):
+        dirty |= True
+    else:
+        # The oldest of the mtime and the ctime
+        date = datetime.fromtimestamp(min([stat.st_mtime, stat.st_ctime]))
+        dirty |= True
+
+    # 944404
+    subsec = int(get_tag("EXIF SubSecTime") or get_tag("EXIF SubSecTimeOriginal") or get_tag("EXIF SubSecTimeDigitized") or "0")
+    date = date.replace(microsecond=subsec)
+
+    return ImgInfo(
+        p,
+        tags,
+        camera_make,
+        camera_model,
+        camera_sn,
+        lens_make,
+        lens_model,
+        lens_sn,
+        software,
+        date,
+        dirty,
+    )
+
+
+def main():
+    opts, args = parser.parse_known_args()
+    skip_tags = [
+        0x927C, # MakerNote
+        0x9286, # UserComment
+        0xC4A5, # PrintIM
+    ]
+
+    print("---")
+    for src in list(opts.src_dir.glob("**/*")):
+        if src.is_dir():
+            continue
+
+        info = img_info(src)
+        year_dir = Path(opts.dest_dir / str(info.date.year))
+        year_dir.mkdir(exist_ok=True)  # Ignore existing and continue
+        # Figure out a stable file name
+        stable_name = f"v1_{info.date.strftime(MODIFIED_ISO_DATE)}_{sanitize(info.camera_make)}_{sanitize(info.camera_model)}_{info.device_fingerprint()}"
+        try:
+            ext = normalize_ext(src)
+        except AssertionError:
+            continue  # Just skip fucked up files
+        target = Path(year_dir / f"{stable_name}{ext}")
+
+        print(f"{src}:")
+        print(f"  rename: {target}")
+        if not target.exists():
+            try:
+                src.rename(target)  # Execute the rename
+            except OSError:
+                copyfile(src, target)
+                target.chmod(0o644)
+                src.unlink()
+        elif src == target:
+            pass  # Nothing to do
+        else:
+            target.chmod(0o644)
+            src.unlink()  # Delete the source
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/python/requirements.txt b/tools/python/requirements.txt
index 570daa0..698e580 100644
--- a/tools/python/requirements.txt
+++ b/tools/python/requirements.txt
@@ -13,6 +13,7 @@ click==7.1.2
 commonmark==0.9.1
 coverage==5.5
 docutils==0.17
+ExifRead==2.3.2
 flake8==3.9.2
 Flask==2.0.1
 hypothesis==6.14.5
@@ -44,6 +45,7 @@ packaging==20.9
 parso==0.8.2
 pathspec==0.9.0
 pep517==0.11.0
+piexif==1.1.3
 pip-tools==6.2.0
 platformdirs==2.3.0
 pluggy==0.13.1