Fix Pixel phone naming
This commit is contained in:
@@ -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}'
|
||||||
|
|||||||
Reference in New Issue
Block a user