Skip to content

Instantly share code, notes, and snippets.

@Maksim-us
Last active April 30, 2025 22:21
Show Gist options
  • Save Maksim-us/a4e6218990c033baa8436722f2cda246 to your computer and use it in GitHub Desktop.
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)
<#
.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