Fix Pixel phone naming

This commit is contained in:
2026-09-25 08:44:15 +02:00
parent f1d29d5109
commit 51b63eb6b6
+80 -8
View File
@@ -7,6 +7,7 @@ import shutil
import subprocess import subprocess
import sys import sys
from datetime import datetime from datetime import datetime
from functools import lru_cache
from pathlib import Path from pathlib import Path
PHONE_PATTERN = re.compile( PHONE_PATTERN = re.compile(
@@ -24,6 +25,22 @@ TELEGRAM_PATTERN = re.compile(
NORMALIZED = re.compile(r'^\d{4}-\d{2}-\d{2} .+') 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. # Windows hidden-file attribute, mirrored from the stat module.
FILE_ATTRIBUTE_HIDDEN = 0x2 FILE_ATTRIBUTE_HIDDEN = 0x2
@@ -33,6 +50,12 @@ STATUS_NO_MATCH = 'skip (no rule matched)'
STATUS_CONFLICT = 'skip (conflict)' 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): def get_exif_date(filepath):
"""Try to get the image creation date via exiftool.""" """Try to get the image creation date via exiftool."""
try: try:
@@ -50,6 +73,55 @@ def get_exif_date(filepath):
return None 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): def get_file_date(filepath):
"""Get file creation date (or modification time as fallback).""" """Get file creation date (or modification time as fallback)."""
stat = filepath.stat() stat = filepath.stat()
@@ -59,12 +131,12 @@ def get_file_date(filepath):
def plan_default_rename(filepath): def plan_default_rename(filepath):
"""Plan a rename using the default pattern-matching mode.""" """Plan a rename using the default pattern-matching mode."""
stem = filepath.stem
ext = filepath.suffix
if NORMALIZED.match(filepath.name): if NORMALIZED.match(filepath.name):
return None, STATUS_ALREADY return None, STATUS_ALREADY
stem = resolve_stem(filepath)
ext = filepath.suffix
tg_match = TELEGRAM_PATTERN.match(stem) tg_match = TELEGRAM_PATTERN.match(stem)
if tg_match: if tg_match:
number, day, month, year, hour, minute, second = tg_match.groups() number, day, month, year, hour, minute, second = tg_match.groups()
@@ -72,7 +144,7 @@ def plan_default_rename(filepath):
match = PHONE_PATTERN.match(stem) match = PHONE_PATTERN.match(stem)
if not match: if not match:
match = PHONE_PATTERN.match(filepath.name) match = PHONE_PATTERN.match(f'{stem}{ext}')
if match: if match:
year, month, day, rest = match.groups() year, month, day, rest = match.groups()
new_name = f'{year}-{month}-{day} {rest}' new_name = f'{year}-{month}-{day} {rest}'
@@ -91,7 +163,7 @@ def plan_parse_rename(filepath):
return None, STATUS_ALREADY return None, STATUS_ALREADY
date_str = None date_str = None
if shutil.which('exiftool') is not None: if has_exiftool():
date_str = get_exif_date(filepath) date_str = get_exif_date(filepath)
if not date_str: 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.""" """Resolve a date string for a file, or None if unavailable."""
if parse_mode: if parse_mode:
date_str = None date_str = None
if shutil.which('exiftool') is not None: if has_exiftool():
date_str = get_exif_date(filepath) date_str = get_exif_date(filepath)
if not date_str: if not date_str:
date_str = get_file_date(filepath) date_str = get_file_date(filepath)
return date_str return date_str
# Default pattern mode: extract date from filename # 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") # Check if already normalized (e.g. "2024-06-01 breakfast.jpg")
norm_match = re.match(r'^(\d{4})-(\d{2})-(\d{2}) ', filepath.name) 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 # Try the standard pattern
match = PHONE_PATTERN.match(stem) match = PHONE_PATTERN.match(stem)
if not match: if not match:
match = PHONE_PATTERN.match(filepath.name) match = PHONE_PATTERN.match(f'{stem}{filepath.suffix}')
if match: if match:
year, month, day, _ = match.groups() year, month, day, _ = match.groups()
return f'{year}-{month}-{day}' return f'{year}-{month}-{day}'