Created
September 19, 2025 09:12
-
-
Save dreaded369/2e4394de8d51ae4d83f73e326c05650e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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