Last active
April 30, 2025 22:21
-
-
Save Maksim-us/a4e6218990c033baa8436722f2cda246 to your computer and use it in GitHub Desktop.
A PowerShell script to convert JSON files generated by Google Photos Takeout to XMP sidecar files to be used in Immich (with external libraries)
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
| <# | |
| .DESCRIPTION | |
| Converts Google Photos Takeout JSON files to XMP sidecar files for Immich | |
| - Prompts for a folder containing photos & JSON via a FolderBrowserDialog | |
| - Logs errors/exceptions to JSONroXMP.log (in the same directory as this script) | |
| - Prompts once to overwrite or skip existing XMP files | |
| - Replaces placeholders in the XMP template with actual values from the JSON | |
| .NOTES | |
| Author: Maksim-us | |
| Version: 1.0 | |
| #> | |
| # ------------------------------------------------------------------------------ | |
| # Load the .NET assembly for Windows.Forms (for the folder dialog) | |
| # ------------------------------------------------------------------------------ | |
| [System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") | Out-Null | |
| # ------------------------------------------------------------------------------ | |
| # Prompt user to pick a folder | |
| # ------------------------------------------------------------------------------ | |
| $folderBrowser = New-Object System.Windows.Forms.FolderBrowserDialog | |
| $folderBrowser.Description = "Select the root folder containing your photos and JSON files." | |
| $folderBrowser.ShowNewFolderButton = $false | |
| $dialogResult = $folderBrowser.ShowDialog() | |
| if ($dialogResult -ne [System.Windows.Forms.DialogResult]::OK) { | |
| Write-Host "Folder selection canceled by user. Exiting script." | |
| return | |
| } | |
| # The user-selected folder | |
| $photoDir = $folderBrowser.SelectedPath | |
| # ------------------------------------------------------------------------------ | |
| # Determine where this script lives | |
| # ------------------------------------------------------------------------------ | |
| $scriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent | |
| if (-not $scriptDir) { | |
| $scriptDir = Split-Path $PSCommandPath -Parent | |
| } | |
| # ------------------------------------------------------------------------------ | |
| # Prepare error log file (only errors will go here) | |
| # ------------------------------------------------------------------------------ | |
| $logFile = Join-Path $scriptDir "JSONroXMP.log" | |
| If (Test-Path $logFile) { Remove-Item $logFile -ErrorAction SilentlyContinue } | |
| # ------------------------------------------------------------------------------ | |
| # Display header to console | |
| # ------------------------------------------------------------------------------ | |
| Write-Host "=============================================================" | |
| Write-Host " Google Photos JSON → XMP Converter" | |
| Write-Host "=============================================================" | |
| Write-Host "Script directory: $scriptDir" | |
| Write-Host "Photo directory : $photoDir" | |
| Write-Host "" | |
| # ------------------------------------------------------------------------------ | |
| # Ask if existing XMP files should be overwritten or skipped | |
| # ------------------------------------------------------------------------------ | |
| $overwriteChoice = Read-Host "If an XMP file already exists, Overwrite (O) or Skip (S)? [O/S]" | |
| $overwriteChoice = $overwriteChoice.ToUpper() | |
| while ($overwriteChoice -notin @("O","S")) { | |
| Write-Host "Invalid selection. Please enter 'O' or 'S'." | |
| $overwriteChoice = Read-Host "If an XMP file already exists, Overwrite (O) or Skip (S)? [O/S]" | |
| $overwriteChoice = $overwriteChoice.ToUpper() | |
| } | |
| Write-Host "`nYou selected: $overwriteChoice" | |
| # ------------------------------------------------------------------------------ | |
| # Counters for summary | |
| # ------------------------------------------------------------------------------ | |
| $processedCount = 0 | |
| $createdCount = 0 | |
| $skippedCount = 0 | |
| # ------------------------------------------------------------------------------ | |
| # Function: Generate correct XMP filename | |
| # ------------------------------------------------------------------------------ | |
| function Get-XmpFileName { | |
| param( | |
| [string]$jsonBaseName # the filename after removing .json | |
| ) | |
| # some files do not have the correct .jpg.json extension (just .json) this will add ".jpg" if any of the below are not present. Need to be reworked | |
| $commonImageExts = @("jpg","jpeg","png","gif","heic","tif","tiff","bmp","avi","mp4","mov") | |
| $regexPattern = "^(.*)\.(" + ($commonImageExts -join "|") + ")\((\d+)\)$" | |
| if ($jsonBaseName -match $regexPattern) { | |
| # Rebuild: prefix(counter).extension | |
| $prefix = $Matches[1] | |
| $extension = $Matches[2] | |
| $counter = $Matches[3] | |
| return "$prefix($counter).$extension.xmp" | |
| } | |
| else { | |
| # Check if base name ends with a recognized extension | |
| $endsWithExt = $false | |
| foreach ($ext in $commonImageExts) { | |
| if ($jsonBaseName.ToLower().EndsWith(".$ext")) { | |
| $endsWithExt = $true | |
| break | |
| } | |
| } | |
| # If no recognized extension, append ".jpg" | |
| if (-not $endsWithExt) { | |
| $jsonBaseName += ".jpg" | |
| } | |
| return "$jsonBaseName.xmp" | |
| } | |
| } | |
| # ------------------------------------------------------------------------------ | |
| # MAIN PROCESS | |
| # ------------------------------------------------------------------------------ | |
| if (-not (Test-Path $photoDir)) { | |
| Write-Host "ERROR: Directory does not exist: $photoDir" | |
| return | |
| } | |
| Get-ChildItem -Path $photoDir -Filter "*.json" -Recurse | ForEach-Object { | |
| $processedCount++ | |
| $jsonFile = $_.FullName | |
| $jsonData = Get-Content $jsonFile -Raw | ConvertFrom-Json | |
| $currentDir = $_.DirectoryName | |
| $jsonBaseName = $_.BaseName | |
| # Determine final XMP filename | |
| $xmpName = Get-XmpFileName -jsonBaseName $jsonBaseName | |
| $xmpFile = Join-Path $currentDir $xmpName | |
| # Overwrite or skip logic | |
| if ((Test-Path $xmpFile) -and ($overwriteChoice -eq "S")) { | |
| Write-Host "Skipping existing XMP file: $xmpFile" | |
| $skippedCount++ | |
| return | |
| } | |
| # Convert Unix timestamp to date | |
| $unixTimestamp = $jsonData.photoTakenTime.timestamp | |
| $dateTime = (Get-Date "1970-01-01 00:00:00.000Z").AddSeconds($unixTimestamp) | |
| $xmpDate = $dateTime.ToString("yyyy:MM:dd HH:mm:ss") | |
| # Build optional GPS string | |
| $lat = $jsonData.geoData.latitude | |
| $lng = $jsonData.geoData.longitude | |
| $gpsString = "" | |
| if (($lat -ne 0) -or ($lng -ne 0)) { | |
| $latRef = if ($lat -ge 0) { "N" } else { "S" } | |
| $lngRef = if ($lng -ge 0) { "E" } else { "W" } | |
| $absLat = [Math]::Abs($lat) | |
| $absLng = [Math]::Abs($lng) | |
| $latDeg = [Math]::Floor($absLat) | |
| $latMin = ($absLat - $latDeg) * 60 | |
| $lngDeg = [Math]::Floor($absLng) | |
| $lngMin = ($absLng - $lngDeg) * 60 | |
| # Insert placeholders, then replace them | |
| $gpsString = @' | |
| <rdf:Description rdf:about="" | |
| xmlns:exif="http://ns.adobe.com/exif/1.0/"> | |
| <exif:GPSLatitude>LAT_DEG,LAT_MINLAT_REF</exif:GPSLatitude> | |
| <exif:GPSLatitudeRef>LAT_REF</exif:GPSLatitudeRef> | |
| <exif:GPSLongitude>LNG_DEG,LNG_MINLNG_REF</exif:GPSLongitude> | |
| <exif:GPSLongitudeRef>LNG_REF</exif:GPSLongitudeRef> | |
| </rdf:Description> | |
| '@ | |
| $gpsString = $gpsString.Replace("LAT_DEG", $latDeg) | |
| $gpsString = $gpsString.Replace("LAT_MIN", $latMin.ToString("0.000000")) | |
| $gpsString = $gpsString.Replace("LAT_REF", $latRef) | |
| $gpsString = $gpsString.Replace("LNG_DEG", $lngDeg) | |
| $gpsString = $gpsString.Replace("LNG_MIN", $lngMin.ToString("0.000000")) | |
| $gpsString = $gpsString.Replace("LNG_REF", $lngRef) | |
| } | |
| # Main XMP content (single-quoted here-string) | |
| $xmpContent = @' | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 6.0.0"> | |
| <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> | |
| <rdf:Description rdf:about="" | |
| xmlns:xmp="http://ns.adobe.com/xap/1.0/" | |
| xmlns:dc="http://purl.org/dc/elements/1.1/" | |
| xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/" | |
| xmlns:xmpMM="http://ns.adobe.com/xap/1.0/mm/" | |
| xmlns:exif="http://ns.adobe.com/exif/1.0/"> | |
| <xmp:CreateDate>XMP_DATE</xmp:CreateDate> | |
| <xmp:ModifyDate>XMP_DATE</xmp:ModifyDate> | |
| <xmp:MetadataDate>XMP_DATE</xmp:MetadataDate> | |
| <dc:title> | |
| <rdf:Alt> | |
| <rdf:li xml:lang="x-default">JSON_TITLE</rdf:li> | |
| </rdf:Alt> | |
| </dc:title> | |
| <dc:description> | |
| <rdf:Alt> | |
| <rdf:li xml:lang="x-default">JSON_DESCRIPTION</rdf:li> | |
| </rdf:Alt> | |
| </dc:description> | |
| <photoshop:DateCreated>XMP_DATE</photoshop:DateCreated> | |
| <exif:DateTimeOriginal>XMP_DATE</exif:DateTimeOriginal> | |
| </rdf:Description> | |
| GPS_BLOCK | |
| </rdf:RDF> | |
| </x:xmpmeta> | |
| '@ | |
| # Replace placeholders (literal string replacement) | |
| $xmpContent = $xmpContent.Replace("XMP_DATE", $xmpDate) | |
| $xmpContent = $xmpContent.Replace("JSON_TITLE", $jsonData.title) | |
| $xmpContent = $xmpContent.Replace("JSON_DESCRIPTION", $jsonData.description) | |
| if ($gpsString) { | |
| $xmpContent = $xmpContent.Replace("GPS_BLOCK", $gpsString) | |
| } | |
| else { | |
| $xmpContent = $xmpContent.Replace("GPS_BLOCK", "") | |
| } | |
| # Write out the XMP file | |
| $xmpContent | Out-File -FilePath $xmpFile -Encoding UTF8 | |
| $createdCount++ | |
| Write-Host "Created XMP file: $xmpFile" | |
| } | |
| Write-Host "`n=============================================================" | |
| Write-Host "Processing complete!" | |
| Write-Host "Total JSON files processed: $processedCount" | |
| Write-Host "Total XMP files created : $createdCount" | |
| Write-Host "Total XMP files skipped : $skippedCount" | |
| Write-Host "=============================================================" | |
| # ------------------------------------------------------------------------------ | |
| # Check if any errors occurred during the run | |
| # If so, write them to JSONtoXMP.log | |
| # ------------------------------------------------------------------------------ | |
| if ($Error.Count -gt 0) { | |
| # Prepend a note to the log | |
| "PowerShell encountered $($Error.Count) error(s) while running this script:" | Out-File $logFile | |
| # Write each error line | |
| $Error | Out-File -Append $logFile | |
| Write-Host "`nERRORS occurred. See `"$logFile`" for details." | |
| } else { | |
| Write-Host "`nNo errors occurred." | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment