Skip to content

Instantly share code, notes, and snippets.

@dreaded369
Created September 19, 2025 09:12
Show Gist options
  • Select an option

  • Save dreaded369/2e4394de8d51ae4d83f73e326c05650e to your computer and use it in GitHub Desktop.

Select an option

Save dreaded369/2e4394de8d51ae4d83f73e326c05650e to your computer and use it in GitHub Desktop.
# google_photos_metadata_fixer by TheOviPlay
# Source: https://xdaforums.com/t/tool-google-photos-metadata-fixer-restore-exif-creation-dates-after-download.4734623/
# Needs exiftool
import os
import json
import shutil
import subprocess
import re
from datetime import datetime
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from threading import Thread
class MetadataProcessor:
def __init__(self, root):
self.root = root
self.root.title("Google Photos Metadata Processor")
self.root.geometry("800x600")
# Language settings
self.language = "pl" # Default language
self.translations = {
"pl": {
"title": "Google Photos Metadata Processor",
"folders": "Foldery",
"source_folder": "Folder źródłowy:",
"browse": "Przeglądaj",
"target_folder": "Folder docelowy:",
"options": "Opcje",
"delete_originals": "Usuń oryginalne pliki po przetworzeniu",
"convert_heic": "Konwertuj HEIC do JPEG",
"progress": "Postęp",
"ready": "Gotowy do rozpoczęcia",
"scan": "Skanuj foldery",
"process": "Przetwórz pliki",
"clear_log": "Wyczyść log",
"scanning": "Rozpoczynanie skanowania...",
"files_found": "Znaleziono {} plików do przetworzenia",
"no_files": "Nie znaleziono plików do przetworzenia",
"confirm": "Potwierdzenie",
"confirm_message": "Czy na pewno chcesz przetworzyć {} plików?",
"processing": "Przetwarzanie... {}% (Sukces: {}, Błędy: {}, Pominięte: {})",
"complete": "Zakończono (Sukces: {}, Błędy: {}, Pominięte: {})",
"error": "Błąd",
"error_scanning": "Wystąpił błąd podczas skanowania:\n{}",
"processing_error": "Wystąpił błąd podczas przetwarzania:\n{}",
"info": "Informacja",
"select_source": "Wybierz folder źródłowy",
"select_target": "Wybierz folder docelowy",
"complete_title": "Zakończono",
"complete_message": "Przetwarzanie zakończone!\n\nSukces: {}\nBłędy: {}\nPominięte: {}\n\nSzczegóły błędów w logu.",
"language": "Język",
"polish": "Polski",
"english": "Angielski",
"exiftool_missing": "Nie znaleziono exiftool. Proszę zainstalować ExifTool ze strony https://exiftool.org/"
},
"en": {
"title": "Google Photos Metadata Processor",
"folders": "Folders",
"source_folder": "Source folder:",
"browse": "Browse",
"target_folder": "Target folder:",
"options": "Options",
"delete_originals": "Delete original files after processing",
"convert_heic": "Convert HEIC to JPEG",
"progress": "Progress",
"ready": "Ready to start",
"scan": "Scan folders",
"process": "Process files",
"clear_log": "Clear log",
"scanning": "Starting scan...",
"files_found": "Found {} files to process",
"no_files": "No files found to process",
"confirm": "Confirmation",
"confirm_message": "Are you sure you want to process {} files?",
"processing": "Processing... {}% (Success: {}, Errors: {}, Skipped: {})",
"complete": "Complete (Success: {}, Errors: {}, Skipped: {})",
"error": "Error",
"error_scanning": "Error while scanning:\n{}",
"processing_error": "Error while processing:\n{}",
"info": "Information",
"select_source": "Select source folder",
"select_target": "Select target folder",
"complete_title": "Complete",
"complete_message": "Processing complete!\n\nSuccess: {}\nErrors: {}\nSkipped: {}\n\nError details in the log.",
"language": "Language",
"polish": "Polish",
"english": "English",
"exiftool_missing": "exiftool not found. Please install ExifTool from https://exiftool.org/"
}
}
# State variables
self.source_dir = tk.StringVar()
self.output_dir = tk.StringVar()
self.progress = tk.DoubleVar()
self.status_text = tk.StringVar(value=self._("ready"))
self.files_to_process = []
self.processed_files = 0
self.total_files = 0
self.running = False
self.delete_originals = tk.BooleanVar(value=False)
self.convert_heic = tk.BooleanVar(value=False)
# Check exiftool availability
self.exiftool_available = self.check_exiftool_availability()
if not self.exiftool_available:
messagebox.showerror(self._("error"), self._("exiftool_missing"))
# UI
self.create_widgets()
def _(self, text_key):
"""Helper method to get translated text"""
return self.translations[self.language].get(text_key, text_key)
def check_exiftool_availability(self):
"""Check if exiftool is installed and available"""
try:
result = subprocess.run(['exiftool', '-ver'], capture_output=True, text=True)
return result.returncode == 0
except FileNotFoundError:
return False
def change_language(self, new_lang):
"""Change application language"""
self.language = new_lang
self.update_ui_text()
def update_ui_text(self):
"""Update all UI elements with current language"""
self.root.title(self._("title"))
# Update frames
self.dir_frame.config(text=self._("folders"))
self.options_frame.config(text=self._("options"))
self.progress_frame.config(text=self._("progress"))
# Update labels
self.source_label.config(text=self._("source_folder"))
self.target_label.config(text=self._("target_folder"))
# Update buttons
self.scan_button.config(text=self._("scan"))
self.process_button.config(text=self._("process"))
self.clear_button.config(text=self._("clear_log"))
# Update checkboxes
self.delete_check.config(text=self._("delete_originals"))
self.convert_check.config(text=self._("convert_heic"))
# Update status
if not self.running:
self.status_text.set(self._("ready"))
# Update language menu
self.lang_menu.entryconfig(0, label=f"{self._('polish')} (PL)")
self.lang_menu.entryconfig(1, label=f"{self._('english')} (EN)")
def create_widgets(self):
# Main frame
main_frame = ttk.Frame(self.root, padding="10")
main_frame.pack(fill=tk.BOTH, expand=True)
# Menu bar
menubar = tk.Menu(self.root)
self.root.config(menu=menubar)
# Language menu
lang_menu = tk.Menu(menubar, tearoff=0)
lang_menu.add_command(label=f"{self._('polish')} (PL)", command=lambda: self.change_language("pl"))
lang_menu.add_command(label=f"{self._('english')} (EN)", command=lambda: self.change_language("en"))
menubar.add_cascade(label=self._("language"), menu=lang_menu)
self.lang_menu = lang_menu
# Directory section
self.dir_frame = ttk.LabelFrame(main_frame, text=self._("folders"), padding="10")
self.dir_frame.pack(fill=tk.X, pady=5)
self.source_label = ttk.Label(self.dir_frame, text=self._("source_folder"))
self.source_label.grid(row=0, column=0, sticky=tk.W)
ttk.Entry(self.dir_frame, textvariable=self.source_dir, width=50).grid(row=0, column=1, padx=5)
ttk.Button(self.dir_frame, text=self._("browse"), command=self.browse_source).grid(row=0, column=2)
self.target_label = ttk.Label(self.dir_frame, text=self._("target_folder"))
self.target_label.grid(row=1, column=0, sticky=tk.W)
ttk.Entry(self.dir_frame, textvariable=self.output_dir, width=50).grid(row=1, column=1, padx=5)
ttk.Button(self.dir_frame, text=self._("browse"), command=self.browse_output).grid(row=1, column=2)
# Options section
self.options_frame = ttk.LabelFrame(main_frame, text=self._("options"), padding="10")
self.options_frame.pack(fill=tk.X, pady=5)
self.delete_check = ttk.Checkbutton(self.options_frame, variable=self.delete_originals)
self.delete_check.pack(anchor=tk.W)
self.convert_check = ttk.Checkbutton(self.options_frame, variable=self.convert_heic)
self.convert_check.pack(anchor=tk.W)
# Progress section
self.progress_frame = ttk.LabelFrame(main_frame, text=self._("progress"), padding="10")
self.progress_frame.pack(fill=tk.BOTH, expand=True, pady=5)
ttk.Label(self.progress_frame, textvariable=self.status_text).pack(anchor=tk.W)
self.progress_bar = ttk.Progressbar(self.progress_frame, variable=self.progress, maximum=100)
self.progress_bar.pack(fill=tk.X, pady=5)
self.log_text = tk.Text(self.progress_frame, height=10, state=tk.DISABLED)
self.log_text.pack(fill=tk.BOTH, expand=True)
# Action buttons
button_frame = ttk.Frame(main_frame)
button_frame.pack(fill=tk.X, pady=5)
self.scan_button = ttk.Button(button_frame, command=self.scan_files)
self.scan_button.pack(side=tk.LEFT, padx=5)
self.process_button = ttk.Button(button_frame, command=self.start_processing, state=tk.DISABLED)
self.process_button.pack(side=tk.LEFT, padx=5)
self.clear_button = ttk.Button(button_frame, command=self.clear_log)
self.clear_button.pack(side=tk.RIGHT, padx=5)
# Update all texts with current language
self.update_ui_text()
def browse_source(self):
directory = filedialog.askdirectory(title=self._("select_source"))
if directory:
self.source_dir.set(directory)
def browse_output(self):
directory = filedialog.askdirectory(title=self._("select_target"))
if directory:
self.output_dir.set(directory)
def log_message(self, message):
self.log_text.config(state=tk.NORMAL)
self.log_text.insert(tk.END, message + "\n")
self.log_text.see(tk.END)
self.log_text.config(state=tk.DISABLED)
self.root.update_idletasks()
def clear_log(self):
self.log_text.config(state=tk.NORMAL)
self.log_text.delete(1.0, tk.END)
self.log_text.config(state=tk.DISABLED)
def scan_files(self):
if not self.source_dir.get():
messagebox.showerror(self._("error"), self._("select_source"))
return
self.log_message(self._("scanning"))
self.scan_button.config(state=tk.DISABLED)
# Run scan in separate thread
Thread(target=self._scan_files_thread, daemon=True).start()
def _scan_files_thread(self):
try:
self.files_to_process = self.find_all_media_json_pairs(self.source_dir.get())
self.total_files = len(self.files_to_process)
self.root.after(0, self._scan_complete)
except Exception as e:
self.root.after(0, self._scan_error, str(e))
def _scan_complete(self):
self.log_message(self._("files_found").format(self.total_files))
self.status_text.set(self._("files_found").format(self.total_files))
if self.total_files > 0:
self.process_button.config(state=tk.NORMAL)
else:
messagebox.showinfo(self._("info"), self._("no_files"))
self.scan_button.config(state=tk.NORMAL)
def _scan_error(self, error):
self.log_message(self._("error_scanning").format(error))
self.scan_button.config(state=tk.NORMAL)
messagebox.showerror(self._("error"), self._("error_scanning").format(error))
def start_processing(self):
if not self.output_dir.get():
messagebox.showerror(self._("error"), self._("select_target"))
return
if not messagebox.askyesno(self._("confirm"),
self._("confirm_message").format(self.total_files)):
return
if not self.exiftool_available:
messagebox.showerror(self._("error"), self._("exiftool_missing"))
return
self.processed_files = 0
self.progress.set(0)
self.running = True
self.scan_button.config(state=tk.DISABLED)
self.process_button.config(state=tk.DISABLED)
self.status_text.set(self._("processing").format(0, 0, 0, 0))
# Run processing in separate thread
Thread(target=self._process_files_thread, daemon=True).start()
def _process_files_thread(self):
try:
output_dir = self.output_dir.get()
delete_originals = self.delete_originals.get()
convert_heic = self.convert_heic.get()
success_count = 0
error_count = 0
skipped_count = 0
for i, (json_path, media_path, _) in enumerate(self.files_to_process):
if not self.running:
break
try:
base_name = os.path.basename(media_path)
# Change extension to jpg if converting HEIC
if convert_heic and os.path.splitext(base_name)[1].lower() in {'.heic', '.heif'}:
base_name = os.path.splitext(base_name)[0] + '.jpg'
output_path = os.path.join(output_dir, base_name)
result = self.update_metadata_with_exiftool(json_path, media_path, output_path)
if result is True:
success_count += 1
if delete_originals:
try:
os.remove(json_path)
os.remove(media_path)
except Exception as e:
self.root.after(0, self.log_message, f"⚠ {self._('error')} {str(e)}")
elif result is False:
error_count += 1
else: # None means no metadata to update
skipped_count += 1
progress = (i + 1) / self.total_files * 100
self.root.after(0, self._update_progress, progress, i+1, success_count, error_count, skipped_count)
except Exception as e:
error_count += 1
self.root.after(0, self.log_message, f"✗ {self._('error')} {os.path.basename(media_path)}: {str(e)}")
self.root.after(0, self._processing_complete, success_count, error_count, skipped_count)
except Exception as e:
self.root.after(0, self._processing_error, str(e))
def _update_progress(self, progress, processed, success, errors, skipped):
self.progress.set(progress)
self.status_text.set(self._("processing").format(int(progress), success, errors, skipped))
self.log_message(f"{self._('files_found').format(processed)}/{self.total_files}")
def _processing_complete(self, success, errors, skipped):
self.running = False
self.scan_button.config(state=tk.NORMAL)
self.process_button.config(state=tk.NORMAL)
messagebox.showinfo(self._("complete_title"),
self._("complete_message").format(success, errors, skipped))
self.status_text.set(self._("complete").format(success, errors, skipped))
def _processing_error(self, error):
self.running = False
self.scan_button.config(state=tk.NORMAL)
self.process_button.config(state=tk.NORMAL)
messagebox.showerror(self._("error"), self._("processing_error").format(error))
self.status_text.set(self._("error"))
def find_all_media_json_pairs(self, root_dir):
"""Find all media-JSON pairs in folder and subfolders"""
media_extensions = {'.jpg', '.jpeg', '.png', '.mp4', '.mov', '.heic', '.heif', '.gif', '.bmp', '.tiff'}
pairs = []
for foldername, subfolders, filenames in os.walk(root_dir):
# Find all media and JSON files
media_files = [f for f in filenames
if os.path.splitext(f)[1].lower() in media_extensions]
json_files = [f for f in filenames
if f.lower().endswith('.json')]
# For each media file look for matching JSON
for media_file in media_files:
media_name = os.path.splitext(media_file)[0]
media_ext = os.path.splitext(media_file)[1].lower()
media_base = self.normalize_filename(media_name)
# Possible JSON filename variants
possible_json_names = [
media_name + '.json', # IMG_1234.json
media_file + '.json', # IMG_1234.jpg.json
media_name + '.supplemental-metadata.json', # IMG_1234.supplemental-metadata.json
media_name.replace('(1)', '') + '.json', # For files with (1) in name
re.sub(r'\(\d+\)', '', media_name) + '.json', # For files with (nr) in name
media_base + '.json', # Normalized name
media_name.split('__')[0] + '.json' # For files with __ in name
]
# Look for matching JSON
for json_file in json_files:
json_name = os.path.splitext(json_file)[0]
# Check all possible matches
if (json_file in possible_json_names or
json_name == media_name or
json_name.startswith(media_name + '.') or
json_name.startswith(media_base + '.') or
json_name == media_name + '.supplemental-metadata' or
json_name.replace('.supplemental-metadata', '') == media_name or
json_name.replace('(1)', '') == media_name.replace('(1)', '') or
json_name.split('__')[0] == media_name.split('__')[0]):
media_path = os.path.join(foldername, media_file)
json_path = os.path.join(foldername, json_file)
pairs.append((json_path, media_path, foldername))
break
return pairs
def normalize_filename(self, filename):
"""Normalize filename by removing special characters and spaces"""
# Remove (1), (2) etc.
normalized = re.sub(r'\(\d+\)', '', filename)
# Replace all special characters with _
normalized = re.sub(r'[^\w.-]', '_', normalized)
# Replace multiple _ with single
normalized = re.sub(r'_+', '_', normalized)
return normalized.lower()
def update_metadata_with_exiftool(self, json_path, media_path, output_path):
"""Update metadata and save to new location"""
try:
with open(json_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
commands = []
# Date - only if timestamp exists
if 'photoTakenTime' in metadata:
timestamp = metadata['photoTakenTime'].get('timestamp')
if timestamp and str(timestamp).isdigit():
date_time = datetime.utcfromtimestamp(int(timestamp)).strftime('%Y:%m:%d %H:%M:%S')
commands.extend([
'-DateTimeOriginal=' + date_time,
'-CreateDate=' + date_time,
'-ModifyDate=' + date_time
])
# GPS - only if values are not 0
if 'geoData' in metadata:
geo_data = metadata['geoData']
lat = geo_data.get('latitude', 0)
lon = geo_data.get('longitude', 0)
if lat != 0 and lon != 0:
commands.extend([
'-GPSLatitude=' + str(abs(lat)),
'-GPSLongitude=' + str(abs(lon)),
'-GPSLatitudeRef=' + ('N' if lat >= 0 else 'S'),
'-GPSLongitudeRef=' + ('E' if lon >= 0 else 'W')
])
# Title - only if different from filename
if 'title' in metadata:
title = metadata['title']
media_name = os.path.splitext(os.path.basename(media_path))[0]
if title and title != media_name:
commands.append('-Description=' + title)
if commands:
# Create target directory if it doesn't exist
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Check if we're converting HEIC to JPEG
is_heic = os.path.splitext(media_path)[1].lower() in {'.heic', '.heif'}
convert_heic = self.convert_heic.get() and is_heic
if convert_heic:
# First convert HEIC to JPEG using exiftool
temp_jpg = os.path.splitext(output_path)[0] + '_temp.jpg'
try:
subprocess.run(['exiftool', '-b', '-PreviewImage', media_path, '-w', temp_jpg],
check=True, capture_output=True)
# Replace original path with the converted one
media_path = temp_jpg
except subprocess.CalledProcessError as e:
self.root.after(0, self.log_message, f"✗ Błąd konwersji HEIC: {e.stderr}")
return False
# Copy file to new location
shutil.copy2(media_path, output_path)
# For HEIC files, try without overwrite_original first
if is_heic and not convert_heic:
cmd = ['exiftool'] + commands + [output_path]
else:
cmd = ['exiftool', '-overwrite_original'] + commands + [output_path]
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
# If HEIC and first attempt failed, try different approach
if is_heic and not convert_heic and result.returncode != 0:
# 1. Try to save to temp file
temp_path = output_path + '_temp'
shutil.copy2(media_path, temp_path)
cmd = ['exiftool'] + commands + [temp_path]
subprocess.run(cmd, capture_output=True, text=True, check=True)
# 2. Replace files
os.replace(temp_path, output_path)
self.root.after(0, self.log_message, f"✓ Przetworzono: {os.path.basename(output_path)}")
return True
except subprocess.CalledProcessError as e:
error_msg = f"Błąd exiftool dla {os.path.basename(output_path)}: {e.stderr.strip()}"
self.root.after(0, self.log_message, f"✗ {error_msg}")
if os.path.exists(output_path):
try:
os.remove(output_path)
except:
pass
return False
finally:
if convert_heic and os.path.exists(temp_jpg):
os.remove(temp_jpg)
else:
self.root.after(0, self.log_message, f"⚠ Brak metadanych do aktualizacji dla {os.path.basename(output_path)}")
return None
except Exception as e:
self.root.after(0, self.log_message, f"✗ Błąd podczas przetwarzania {os.path.basename(media_path)}: {str(e)}")
if os.path.exists(output_path):
try:
os.remove(output_path)
except:
pass
return False
if __name__ == '__main__':
root = tk.Tk()
app = MetadataProcessor(root)
root.mainloop()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment