feat: MP4 date support via moov>mvhd binary parse (no deps)

This commit is contained in:
admin committed 2026-06-29 14:09:30 +02:00
1 parent 5a00dc1ecd
commit 410a5dc246
1 file changed
+62 -3
+62 -3
View File
@@ -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)