feat: MP4 date support via moov>mvhd binary parse (no deps)
This commit is contained in:
1 parent
5a00dc1ecd
commit
410a5dc246
1 file changed
+62
-3
+62
-3
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user