diff --git a/organize.py b/organize.py index fe43096..a194ba2 100644 --- a/organize.py +++ b/organize.py @@ -19,10 +19,11 @@ import argparse import csv import os import shutil +import struct import sys import time from collections import Counter -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Any @@ -63,6 +64,7 @@ EXTENSIONS = { '.jpg', '.jpeg', '.png', '.heic', '.tiff', '.tif', '.webp', '.bmp', '.cr2', '.cr3', '.nef', '.arw', '.dng', '.avi', + '.mp4', } # ANSI colour codes @@ -86,7 +88,7 @@ def _c(text: str, colour: str, *, no_color: bool = False) -> str: # Tracks the last EXIF field name used (for display in output) _last_exif_field = '' -_EXIF_FIELD_NAMES = {36867: 'DateTimeOriginal', 36868: 'DateTimeDigitized', 306: 'DateTime'} +_EXIF_FIELD_NAMES = {36867: 'DateTimeOriginal', 36868: 'DateTimeDigitized', 306: 'DateTime', 0: 'MP4:mvhd'} def _set_last_field(tag_id: int) -> None: @@ -247,6 +249,53 @@ def _parse_date(date_str: str) -> datetime | None: return None +def _read_mp4_date(path: Path) -> str | None: + """Read creation date from MP4 (moov > mvhd atom). + + MP4 stores creation time in the Movie Header Box (mvhd) + as seconds since January 1, 1904 (Mac epoch). No external + dependencies needed — pure binary parsing. + """ + try: + data = path.read_bytes() + except Exception: + return None + + pos = 0 + size = len(data) + _MAC_EPOCH = datetime(1904, 1, 1) + + try: + while pos < size - 16: + box_size = struct.unpack_from('>I', data, pos)[0] + box_type = data[pos + 4:pos + 8] + if box_size == 0: + break + if box_type == b'moov': + # Found moov box — scan for mvhd inside + end = pos + box_size + inner = pos + 8 + while inner < end - 16: + inner_sz = struct.unpack_from('>I', data, inner)[0] + inner_ty = data[inner + 4:inner + 8] + if inner_sz == 0: + break + if inner_ty == b'mvhd': + ver = data[inner + 8] + if ver == 0: + ctime = struct.unpack_from('>I', data, inner + 12)[0] + else: + ctime = struct.unpack_from('>Q', data, inner + 16)[0] + dt = _MAC_EPOCH + timedelta(seconds=ctime) + return dt.strftime('%Y:%m:%d %H:%M:%S') + inner += inner_sz + break + pos += box_size + except Exception: + pass + return None + + def _get_created_date(path: Path) -> datetime | None: """Fallback: file system creation date (st_ctime on Windows).""" try: @@ -307,6 +356,7 @@ def organize( start_time = time.time() for filepath in iterator: + _last_exif_field = '' dt: datetime | None = None from_exif = False date_source_label: str | None = None @@ -346,13 +396,22 @@ def organize( from_exif = True # ── EXIF tier 5: other formats (TIFF, WebP, BMP, PNG) ────────── - if dt is None and ext not in ('.avi',): + if dt is None and ext not in ('.avi', '.mp4'): date_str = _read_other_exif_pil(filepath) if date_str: dt = _parse_date(date_str) if dt: from_exif = True + # ── EXIF tier 6: MP4 (moov > mvhd binary parse) ────────────── + if dt is None and ext == '.mp4': + date_str = _read_mp4_date(filepath) + if date_str: + dt = _parse_date(date_str) + if dt: + _set_last_field(0) # mark as MP4 + from_exif = True + # ── Fallback: file creation date (always available) ──────────── if dt is None: dt = _get_created_date(filepath)