diff --git a/bin/nn b/bin/nn index ce41202..0fdb7f1 100755 --- a/bin/nn +++ b/bin/nn @@ -7,6 +7,7 @@ import shutil import subprocess import sys from datetime import datetime +from functools import lru_cache from pathlib import Path PHONE_PATTERN = re.compile( @@ -24,6 +25,22 @@ TELEGRAM_PATTERN = re.compile( NORMALIZED = re.compile(r'^\d{4}-\d{2}-\d{2} .+') +# Filename prefixes whose embedded timestamp is UTC rather than local time. +# The Pixel camera names captures in UTC but records the local wall clock time +# in EXIF, so for these the metadata is authoritative and the filename is not. +# Other sources (Samsung's IMG_/VID_, Telegram exports) already name in local +# time and are left alone. +UTC_FILENAME_PREFIXES = ('PXL',) + +# e.g. "PXL_20260917_163001280.MP" -> prefix, timestamp, subseconds, suffix +UTC_STEM_PATTERN = re.compile( + r'^(' + '|'.join(UTC_FILENAME_PREFIXES) + r')[_-]' + r'(\d{8}[_-]\d{6})' + r'(\d*)' + r'(.*)$', + re.IGNORECASE, +) + # Windows hidden-file attribute, mirrored from the stat module. FILE_ATTRIBUTE_HIDDEN = 0x2 @@ -33,6 +50,12 @@ STATUS_NO_MATCH = 'skip (no rule matched)' STATUS_CONFLICT = 'skip (conflict)' +@lru_cache(maxsize=1) +def has_exiftool(): + """Check once whether exiftool is available on PATH.""" + return shutil.which('exiftool') is not None + + def get_exif_date(filepath): """Try to get the image creation date via exiftool.""" try: @@ -50,6 +73,55 @@ def get_exif_date(filepath): return None +def get_exif_timestamp(filepath): + """Get the local capture time via exiftool as (YYYYMMDD_HHMMSS, subsec). + + DateTimeOriginal is the wall clock time at the moment of capture, so it + needs no timezone correction. Returns None when unavailable. + """ + try: + result = subprocess.run( + [ + 'exiftool', '-s3', '-d', '%Y%m%d_%H%M%S', + '-DateTimeOriginal', '-SubSecTimeOriginal', str(filepath), + ], + capture_output=True, + text=True, + timeout=10, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + + lines = result.stdout.strip().splitlines() + if not lines or not re.match(r'^\d{8}_\d{6}$', lines[0].strip()): + return None + + subsec = lines[1].strip() if len(lines) > 1 else '' + return lines[0].strip(), subsec if subsec.isdigit() else '' + + +def resolve_stem(filepath): + """Return the file's stem, with UTC-named timestamps corrected from EXIF. + + Pixel filenames encode UTC, so the name alone is off by the UTC offset in + effect at capture. Falls back to the original stem when the file is not a + UTC-named capture, or when its metadata has no usable timestamp. + """ + stem = filepath.stem + + match = UTC_STEM_PATTERN.match(stem) + if not match or not has_exiftool(): + return stem + + prefix, _, subsec, rest = match.groups() + stamp = get_exif_timestamp(filepath) + if stamp is None: + return stem + + exif_stamp, exif_subsec = stamp + return f'{prefix}_{exif_stamp}{exif_subsec or subsec}{rest}' + + def get_file_date(filepath): """Get file creation date (or modification time as fallback).""" stat = filepath.stat() @@ -59,12 +131,12 @@ def get_file_date(filepath): def plan_default_rename(filepath): """Plan a rename using the default pattern-matching mode.""" - stem = filepath.stem - ext = filepath.suffix - if NORMALIZED.match(filepath.name): return None, STATUS_ALREADY + stem = resolve_stem(filepath) + ext = filepath.suffix + tg_match = TELEGRAM_PATTERN.match(stem) if tg_match: number, day, month, year, hour, minute, second = tg_match.groups() @@ -72,7 +144,7 @@ def plan_default_rename(filepath): match = PHONE_PATTERN.match(stem) if not match: - match = PHONE_PATTERN.match(filepath.name) + match = PHONE_PATTERN.match(f'{stem}{ext}') if match: year, month, day, rest = match.groups() new_name = f'{year}-{month}-{day} {rest}' @@ -91,7 +163,7 @@ def plan_parse_rename(filepath): return None, STATUS_ALREADY date_str = None - if shutil.which('exiftool') is not None: + if has_exiftool(): date_str = get_exif_date(filepath) if not date_str: @@ -118,14 +190,14 @@ def get_date_for_file(filepath, parse_mode): """Resolve a date string for a file, or None if unavailable.""" if parse_mode: date_str = None - if shutil.which('exiftool') is not None: + if has_exiftool(): date_str = get_exif_date(filepath) if not date_str: date_str = get_file_date(filepath) return date_str # Default pattern mode: extract date from filename - stem = filepath.stem + stem = resolve_stem(filepath) # Check if already normalized (e.g. "2024-06-01 breakfast.jpg") norm_match = re.match(r'^(\d{4})-(\d{2})-(\d{2}) ', filepath.name) @@ -141,7 +213,7 @@ def get_date_for_file(filepath, parse_mode): # Try the standard pattern match = PHONE_PATTERN.match(stem) if not match: - match = PHONE_PATTERN.match(filepath.name) + match = PHONE_PATTERN.match(f'{stem}{filepath.suffix}') if match: year, month, day, _ = match.groups() return f'{year}-{month}-{day}'