Skip to content

Instantly share code, notes, and snippets.

@renatocron
Last active December 24, 2025 14:15
Show Gist options
  • Select an option

  • Save renatocron/e4293e5b33f5e31cabc80f61b9533e24 to your computer and use it in GitHub Desktop.

Select an option

Save renatocron/e4293e5b33f5e31cabc80f61b9533e24 to your computer and use it in GitHub Desktop.

Fix Incorrect Photo & Video Timestamps from Google Photos / Immich Takeout (Perl Script)

When importing a Google Photos Takeout into Immich (or any self-hosted photo library), many images and videos end up with wrong dates. This is especially common for Facebook Messenger media, screenshots, and older files where EXIF data is missing or corrupted.

Google Photos often stores the correct timestamp only in JSON sidecar files (e.g. *.supplemental-metadata.json), while the actual media files get imported with incorrect filesystem times — frequently placing photos in the wrong year (such as “Photos from 2013”).

This Perl script repairs media timestamps by:

  • Scanning a Takeout directory recursively

  • Loading all media files (jpg, png, gif, webp, mp4, etc.) into memory for fast lookup

  • Matching each JSON metadata file to its real media file (including (1) duplicates and -edited variants)

  • Extracting the correct timestamp from:

    • photoTakenTime.timestamp
    • or creationTime.timestamp
  • Applying the timestamp directly to the media file using utime

  • Logging every change and producing a clear summary

Key Features

  • ✅ Handles duplicate filenames like image(1).jpg
  • ✅ Supports Google’s messy JSON naming (supplemental-metadata, supplement, ..json)
  • ✅ Works even when EXIF data is missing
  • ✅ Fast hash-based lookup (O(1)) for large libraries
  • ✅ Safe: skips files with no timestamp or no matching media
  • ✅ Designed for Immich users, but works for any photo manager

Why This Matters

Without fixing timestamps:

  • Photos appear in the wrong year
  • Albums and timelines are broken
  • Deduplication becomes unreliable

This script restores chronological accuracy, making your photo library usable again.

Typical Use Cases

  • Google Photos → Immich migration
  • Cleaning up “Photos from YYYY” folders
  • Facebook Messenger / WhatsApp media with missing EXIF
  • Large Takeout archives (10k+ files)

Output Example

✔ Messenger_5805486384720763732.jpg: 2013-04-16 00:18:58 -> 2014-04-27 13:17:01
== SUMMARY ==
Total JSONs: 812 | Fixed: 641 | OK: 98 | NoMedia: 54 | NoTS: 19 | Errors: 0

If you want, I can also:

  • Write a short Gist title
  • Add usage instructions
  • Add warnings / dry-run notes
  • Optimize keywords for GitHub search (“immich timestamp fix”, “google takeout dates wrong”, etc.)

Just tell me.

#!/usr/bin/env perl
use strict;
use warnings;
use JSON::PP;
use File::Find;
use File::Basename;
use File::stat;
use Time::Piece;
my $ROOT = shift // '.';
my $LOG = shift // './fix_times.log';
my $TARGET_ID = shift // '';
my %stats = (fixed => 0, same => 0, nomedia => 0, notime => 0, errors => 0, total => 0);
# Media extensions (store lowercase for comparison)
my %media_ext = map { lc($_) => 1 } qw(jpg jpeg png gif mp4 m4v webp);
# Phase 1: Load all media files into memory by directory
# Using hash for O(1) lookup: $media_by_dir{$dir}{$filename} = 1
print "Loading media files into memory...\n";
my %media_by_dir;
find(
sub {
return unless -f $_;
return unless /\.([^.]+)$/ && $media_ext{lc($1)};
my $dir = $File::Find::dir;
my $file = $_;
$media_by_dir{$dir}{$file} = 1; # Hash for O(1) lookup
},
$ROOT
);
my $media_count = 0;
$media_count += scalar(keys %{$_}) for values %media_by_dir;
print "Loaded $media_count media files from " . scalar(keys %media_by_dir) . " directories\n";
# Phase 2: Find all JSON files
print "Finding JSON metadata files...\n";
my @json_files;
find(
sub {
return unless -f $_;
# Match: .supplemental-metadata.json, .supplement.json, .supplemen.json, OR ..json (double dot)
return unless /\.(supplemental-metadata|supplement|supplemen|supp).*\.json$/i || /\.\.json$/;
my $full_path = $File::Find::name;
push @json_files, $full_path if !$TARGET_ID || $full_path =~ /\Q$TARGET_ID\E/;
},
$ROOT
);
print "Found " . scalar(@json_files) . " JSON files to process\n\n";
# Open log
open my $log_fh, '>', $LOG or die "Cannot open log: $!";
sub logit {
my $msg = shift;
print $msg;
print $log_fh $msg;
}
logit("== Immich Takeout Timestamp Fixer (Perl - Hash-based) ==\n");
logit("Root: $ROOT\n");
logit("Log: $LOG\n");
logit("Filter: '$TARGET_ID'\n") if $TARGET_ID;
logit("\n");
# Phase 3: Process each JSON and find its media file
for my $json_path (@json_files) {
$stats{total}++;
# Extract timestamp first (fail fast)
my $ts = extract_timestamp($json_path);
unless ($ts && $ts =~ /^\d+$/) {
$stats{notime}++;
next;
}
# Find corresponding media file(s)
my @media_files = find_media_for_json($json_path, \%media_by_dir);
unless (@media_files) {
$stats{nomedia}++;
logit("⚠ NoMedia: $json_path\n") if $TARGET_ID;
next;
}
# Apply timestamp to all matching media files
for my $media_file (@media_files) {
# Get current mtime
my $st = stat($media_file);
unless ($st) {
$stats{errors}++;
next;
}
my $current = $st->mtime;
# Already correct?
if ($current == $ts) {
$stats{same}++;
next;
}
# Apply fix
if (utime($ts, $ts, $media_file)) {
$stats{fixed}++;
# Log every 100 or if filtering
if (1) {
my $old_date = localtime($current)->strftime('%Y-%m-%d %H:%M:%S');
my $new_date = localtime($ts)->strftime('%Y-%m-%d %H:%M:%S');
my $base = basename($media_file);
logit("✔ #$stats{total} $base: $old_date -> $new_date\n");
}
}
else {
$stats{errors}++;
}
}
}
logit("\n== SUMMARY ==\n");
logit("Total JSONs: $stats{total} | Fixed: $stats{fixed} | OK: $stats{same} | ");
logit("NoMedia: $stats{nomedia} | NoTS: $stats{notime} | Errors: $stats{errors}\n");
close $log_fh;
# ============================================================================
# Subroutines
# ============================================================================
sub find_media_for_json {
my ($json_path, $media_by_dir) = @_;
my $dir = dirname($json_path);
my $json_base = basename($json_path);
# Parse JSON filename to extract original title and duplicate number
# Examples:
# Cover.jpg.supplemental-metadata(5).json -> title=Cover.jpg, dup=5
# filename.jpg.supplement(1).json -> title=filename.jpg, dup=1
# image.jpg.supplemen.json -> title=image.jpg, dup=undef
# Screenshot_2023-07-21_com.picpay..json -> title=Screenshot_2023-07-21_com.picpay.jpg, dup=undef (double dot)
my ($title, $json_dup_num);
if ($json_base =~ /^(.+?)\.(supplemental-metadata|supplement|supplemen|supp).*\((\d+)\)\.json$/i) {
# Has duplicate number: Cover.jpg.supplemental-metadata(5).json
($title, $json_dup_num) = ($1, $3);
}
elsif ($json_base =~ /^(.+?)\.(supplemental-metadata|supplement|supplemen|supp).*\.json$/i) {
# No duplicate: Cover.jpg.supplement.json
$title = $1;
$json_dup_num = undef;
}
elsif ($json_base =~ /^(.+?)\.\.json$/) {
# Double dot pattern: Screenshot_2023-07-21_com.picpay..json
# The title in JSON should tell us the real extension
my $stem = $1;
# Try to get real title from JSON content
my $real_title = get_title_from_json("$dir/$json_base");
if ($real_title) {
$title = $real_title;
}
else {
# Fallback: assume .jpg
$title = "$stem.jpg";
}
$json_dup_num = undef;
}
else {
return ();
}
# Get media files hash for this directory
my $files_hash = $media_by_dir->{$dir};
return () unless $files_hash && %$files_hash;
# Extract stem and extension from title
my ($stem, $ext) = $title =~ /^(.+)\.([^.]+)$/;
return () unless $stem && $ext;
# Build candidate list based on whether JSON has duplicate number
my @candidates;
if (defined $json_dup_num) {
# JSON has (N) - ONLY try exact (N) matches
# Cover.jpg.supplemental-metadata(5).json -> Cover(5).jpg, Cover-edited(5).jpg
@candidates = (
"$stem($json_dup_num).$ext", # Cover(5).jpg
"$stem-edited($json_dup_num).$ext", # Cover-edited(5).jpg
);
}
else {
# JSON has NO duplicate number - only try exact match and -edited
# Cover.jpg.supplement.json -> Cover.jpg, Cover-edited.jpg
@candidates = (
$title, # Cover.jpg (exact from JSON title)
"$stem-edited.$ext", # Cover-edited.jpg
);
}
# Search for matching files using hash lookup (O(1))
my @found;
for my $candidate (@candidates) {
if (exists $files_hash->{$candidate}) {
push @found, "$dir/$candidate";
}
}
# Debug output when filtering
if ($TARGET_ID && !@found) {
logit(" DEBUG: Looking for candidates: " . join(", ", @candidates) . "\n");
logit(" DEBUG: Available files in dir: " . join(", ", sort keys %$files_hash) . "\n");
}
return @found;
}
sub extract_timestamp {
my $json_file = shift;
open my $fh, '<', $json_file or return undef;
my $content = do { local $/; <$fh> };
close $fh;
my $data = eval { decode_json($content) };
return undef unless $data;
return $data->{photoTakenTime}{timestamp} || $data->{creationTime}{timestamp};
}
sub get_title_from_json {
my $json_file = shift;
open my $fh, '<', $json_file or return undef;
my $content = do { local $/; <$fh> };
close $fh;
my $data = eval { decode_json($content) };
return undef unless $data;
return $data->{title};
}
#!/usr/bin/env perl
use strict;
use warnings;
use JSON::PP;
use File::Find;
use File::Basename;
use File::stat;
use Time::Piece;
# Script to fix timestamps for media files that have supplemental JSON metadata
# Handles patterns like: filename.jpg.suppl.json, filename.jpg.supp.json
my $ROOT = shift // '.';
my $LOG = shift // './fix_supplemental.log';
my $TARGET_ID = shift // '';
my $DRY_RUN = ($ENV{DRY_RUN} // '0') eq '1';
my %stats = (fixed => 0, same => 0, nomedia => 0, notime => 0, errors => 0, total => 0);
# Media extensions
my %media_ext = map { lc($_) => 1 } qw(jpg jpeg png gif mp4 m4v webp heic mov avi);
print "Finding supplemental JSON files...\n";
my @json_files;
find(
sub {
return unless -f $_;
return unless /\.(suppl?|supplement|supplemen).*\.json$/i;
my $full_path = $File::Find::name;
push @json_files, $full_path if !$TARGET_ID || $full_path =~ /\Q$TARGET_ID\E/;
},
$ROOT
);
print "Found " . scalar(@json_files) . " supplemental JSON files\n\n";
open my $log_fh, '>', $LOG or die "Cannot open log: $!";
sub logit {
my $msg = shift;
print $msg;
print $log_fh $msg;
}
logit("== Immich Takeout Supplemental JSON Fixer ==\n");
logit("Root: $ROOT\n");
logit("Log: $LOG\n");
logit("DRY RUN: " . ($DRY_RUN ? "YES" : "NO") . "\n");
logit("Filter: '$TARGET_ID'\n") if $TARGET_ID;
logit("\n");
for my $json_path (@json_files) {
$stats{total}++;
my $dir = dirname($json_path);
my $json_base = basename($json_path);
# Extract the expected media filename from JSON filename
# artworks-000020692338-qxee59-original.jpg.supp.json -> artworks-000020692338-qxee59-original.jpg
my $expected_media = $json_base;
$expected_media =~ s/\.(suppl?|supplement|supplemen).*\.json$//i;
# Read JSON to get timestamp
my $ts = read_json_timestamp($json_path);
unless ($ts && $ts =~ /^\d+$/) {
$stats{notime}++;
logit("⚠ NoTime: $json_path\n") if $TARGET_ID;
next;
}
# Try to find the media file
my @candidates;
# Strategy 1: Exact match
my $exact_path = "$dir/$expected_media";
if (-f $exact_path) {
push @candidates, $expected_media;
}
# Strategy 2: Look for variations (e.g., -edited suffix)
unless (@candidates) {
my ($base_stem, $ext) = $expected_media =~ /^(.+)\.([^.]+)$/;
if ($base_stem && $ext) {
# Look for files with same extension and similar stem
opendir(my $dh, $dir) or next;
while (my $file = readdir($dh)) {
next unless -f "$dir/$file";
# Check if it's a media file with same extension
if ($file =~ /^(.+)\.\Q$ext\E$/i) {
my $file_stem = $1;
# Check if base_stem is a prefix of file_stem (handles -edited, etc.)
if ($file_stem =~ /^\Q$base_stem\E(-|$)/) {
push @candidates, $file;
}
}
}
closedir($dh);
}
}
unless (@candidates) {
$stats{nomedia}++;
if ($TARGET_ID) {
logit("⚠ NoMedia: $json_path\n");
logit(" Expected: '$expected_media'\n");
}
next;
}
# Apply timestamp to all candidates
for my $media_file (@candidates) {
my $full_path = "$dir/$media_file";
my $st = stat($full_path);
unless ($st) {
$stats{errors}++;
next;
}
my $current = $st->mtime;
if ($current == $ts) {
$stats{same}++;
next;
}
if ($DRY_RUN) {
my $old_date = localtime($current)->strftime('%Y-%m-%d %H:%M:%S');
my $new_date = localtime($ts)->strftime('%Y-%m-%d %H:%M:%S');
logit("✔ [DRY] #$stats{total} $media_file: $old_date -> $new_date\n");
logit(" JSON: $json_base\n");
$stats{fixed}++;
}
elsif (utime($ts, $ts, $full_path)) {
$stats{fixed}++;
if (1) {
my $old_date = localtime($current)->strftime('%Y-%m-%d %H:%M:%S');
my $new_date = localtime($ts)->strftime('%Y-%m-%d %H:%M:%S');
logit("✔ #$stats{total} $media_file: $old_date -> $new_date\n");
logit(" JSON: $json_base\n");
}
}
else {
$stats{errors}++;
}
}
}
logit("\n== SUMMARY ==\n");
logit("Total JSONs: $stats{total} | Fixed: $stats{fixed} | OK: $stats{same} | ");
logit("NoMedia: $stats{nomedia} | NoTS: $stats{notime} | Errors: $stats{errors}\n");
close $log_fh;
sub read_json_timestamp {
my $json_file = shift;
open my $fh, '<', $json_file or return undef;
my $content = do { local $/; <$fh> };
close $fh;
my $data = eval { decode_json($content) };
return undef unless $data;
# Try photoTakenTime first, then creationTime
return $data->{photoTakenTime}{timestamp} || $data->{creationTime}{timestamp};
}
#!/usr/bin/env perl
use strict;
use warnings;
use JSON::PP;
use File::Find;
use File::Basename;
use File::stat;
use Time::Piece;
# This script handles cases where Google Takeout truncated filenames differently
# for the JSON and media files, e.g.:
# JSON: Screenshot_2023-01-24-13-37-45-254_br.com.orig.json
# Media: Screenshot_2023-01-24-13-37-45-254_br.com.origi.jpg
# Title: Screenshot_2023-01-24-13-37-45-254_br.com.original.bank.jpg
#
# Also handles truncated URL-encoded filenames like:
# JSON: http_3A_2F_2Fimagescale.tumblr.com_2Fimage_2F1.json
# Media: http_3A_2F_2Fimagescale.tumblr.com_2Fimage_2F12(3).jpg
my $ROOT = shift // '.';
my $LOG = shift // './fix_truncated.log';
my $TARGET_ID = shift // '';
my $DRY_RUN = ($ENV{DRY_RUN} // '0') eq '1';
my %stats = (fixed => 0, same => 0, nomedia => 0, notime => 0, errors => 0, total => 0, skipped => 0);
# Media extensions
my %media_ext = map { lc($_) => 1 } qw(jpg jpeg png gif mp4 m4v webp);
# Phase 1: Load all media files by directory
print "Loading media files into memory...\n";
my %media_by_dir;
find(
sub {
return unless -f $_;
return unless /\.([^.]+)$/ && $media_ext{lc($1)};
my $dir = $File::Find::dir;
my $file = $_;
$media_by_dir{$dir}{$file} = 1;
},
$ROOT
);
my $media_count = 0;
$media_count += scalar(keys %{$_}) for values %media_by_dir;
print "Loaded $media_count media files from " . scalar(keys %media_by_dir) . " directories\n";
# Phase 2: Find JSON files that are NOT standard supplement patterns
# These are the truncated/weird ones we need to handle
print "Finding non-standard JSON files...\n";
my @json_files;
find(
sub {
return unless -f $_;
return unless /\.json$/i;
# SKIP standard patterns - these are handled by fix_times.pl
return if /\.(supplemental-metadata|supplement|supplemen|supp).*\.json$/i;
return if /\.\.json$/; # double-dot pattern
my $full_path = $File::Find::name;
push @json_files, $full_path if !$TARGET_ID || $full_path =~ /\Q$TARGET_ID\E/;
},
$ROOT
);
print "Found " . scalar(@json_files) . " non-standard JSON files\n\n";
# Open log
open my $log_fh, '>', $LOG or die "Cannot open log: $!";
sub logit {
my $msg = shift;
print $msg;
print $log_fh $msg;
}
logit("== Immich Takeout Truncated Filename Fixer ==\n");
logit("Root: $ROOT\n");
logit("Log: $LOG\n");
logit("DRY RUN: " . ($DRY_RUN ? "YES" : "NO") . "\n");
logit("Filter: '$TARGET_ID'\n") if $TARGET_ID;
logit("\n");
# Phase 3: Process each JSON
for my $json_path (@json_files) {
$stats{total}++;
my $dir = dirname($json_path);
my $json_base = basename($json_path);
# Get JSON stem (remove .json)
my $json_stem = $json_base;
$json_stem =~ s/\.json$//i;
# Remove (N) suffix if present for matching
my ($json_stem_clean, $json_dup) = ($json_stem, undef);
if ($json_stem =~ /^(.+?)\((\d+)\)$/) {
($json_stem_clean, $json_dup) = ($1, $2);
}
# Read JSON to get title and timestamp
my ($title, $ts) = read_json_data($json_path);
unless ($ts && $ts =~ /^\d+$/) {
$stats{notime}++;
logit("⚠ NoTime: $json_path\n") if $TARGET_ID;
next;
}
# Get media files in this directory
my $files_hash = $media_by_dir{$dir};
unless ($files_hash && %$files_hash) {
$stats{nomedia}++;
next;
}
# Find matching media file(s) using prefix matching
my @matches = find_matching_media($json_stem_clean, $json_dup, $title, $files_hash);
unless (@matches) {
$stats{nomedia}++;
if ($TARGET_ID) {
logit("⚠ NoMedia: $json_path\n");
logit(" JSON stem: '$json_stem_clean'" . (defined $json_dup ? " dup=($json_dup)" : "") . "\n");
logit(" Title: '$title'\n") if $title;
logit(" Available: " . join(", ", sort keys %$files_hash) . "\n");
}
next;
}
# Apply timestamp to matches
for my $media_file (@matches) {
my $full_path = "$dir/$media_file";
my $st = stat($full_path);
unless ($st) {
$stats{errors}++;
next;
}
my $current = $st->mtime;
if ($current == $ts) {
$stats{same}++;
next;
}
if ($DRY_RUN) {
my $old_date = localtime($current)->strftime('%Y-%m-%d %H:%M:%S');
my $new_date = localtime($ts)->strftime('%Y-%m-%d %H:%M:%S');
logit("✔ [DRY] #$stats{total} $media_file: $old_date -> $new_date\n");
logit(" JSON: $json_base\n");
$stats{fixed}++;
}
elsif (utime($ts, $ts, $full_path)) {
$stats{fixed}++;
if ($TARGET_ID || $stats{fixed} % 100 == 0) {
my $old_date = localtime($current)->strftime('%Y-%m-%d %H:%M:%S');
my $new_date = localtime($ts)->strftime('%Y-%m-%d %H:%M:%S');
logit("✔ #$stats{total} $media_file: $old_date -> $new_date\n");
logit(" JSON: $json_base\n");
}
}
else {
$stats{errors}++;
}
}
}
logit("\n== SUMMARY ==\n");
logit("Total JSONs: $stats{total} | Fixed: $stats{fixed} | OK: $stats{same} | ");
logit("NoMedia: $stats{nomedia} | NoTS: $stats{notime} | Errors: $stats{errors}\n");
close $log_fh;
# ============================================================================
# Subroutines
# ============================================================================
sub find_matching_media {
my ($json_stem, $json_dup, $title, $files_hash) = @_;
my @found;
# FIRST: Try exact stem match (handles cases with trailing dots, etc.)
for my $file (keys %$files_hash) {
my ($file_stem) = $file =~ /^(.+)\.[^.]+$/;
next unless $file_stem;
# Remove trailing dots from both
my $json_clean = $json_stem;
my $file_clean = $file_stem;
$json_clean =~ s/\.+$//;
$file_clean =~ s/\.+$//;
# Handle duplicate numbers
my ($file_stem_clean, $file_dup) = ($file_clean, undef);
if ($file_clean =~ /^(.+?)\((\d+)\)$/) {
($file_stem_clean, $file_dup) = ($1, $2);
}
my ($json_stem_clean, $json_dup_check) = ($json_clean, $json_dup);
if (!defined $json_dup_check && $json_clean =~ /^(.+?)\((\d+)\)$/) {
($json_stem_clean, $json_dup_check) = ($1, $2);
}
# Check duplicate numbers match
if (defined $json_dup_check || defined $file_dup) {
next unless (defined $json_dup_check && defined $file_dup && $json_dup_check eq $file_dup);
}
# Exact match
if ($json_stem_clean eq $file_stem_clean) {
return ($file);
}
}
# SECOND: Try very strict prefix match (for genuine truncation)
# Require at least 90% match OR 40+ characters match
my $min_strict_prefix = length($json_stem) > 40 ? 40 : int(length($json_stem) * 0.9);
# Also, for Screenshot files with timestamps, verify the timestamp portion matches
my $json_has_timestamp = $json_stem =~ /^Screenshot_(\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2})/;
my $json_timestamp = $json_has_timestamp ? $1 : '';
for my $file (keys %$files_hash) {
my ($file_stem) = $file =~ /^(.+)\.[^.]+$/;
next unless $file_stem;
# If JSON has timestamp in filename, media MUST have same timestamp
if ($json_timestamp) {
next unless $file_stem =~ /^Screenshot_\Q$json_timestamp\E/;
}
my ($file_stem_clean, $file_dup) = ($file_stem, undef);
if ($file_stem =~ /^(.+?)\((\d+)\)$/) {
($file_stem_clean, $file_dup) = ($1, $2);
}
# Check duplicate numbers
if (defined $json_dup) {
next unless defined $file_dup && $file_dup eq $json_dup;
}
my $prefix_len = common_prefix_length($json_stem, $file_stem_clean);
if ($prefix_len >= $min_strict_prefix) {
push @found, $file;
}
}
return @found;
}
sub common_prefix_length {
my ($a, $b) = @_;
my $len = 0;
my $max = length($a) < length($b) ? length($a) : length($b);
for my $i (0 .. $max - 1) {
if (substr($a, $i, 1) eq substr($b, $i, 1)) {
$len++;
}
else {
last;
}
}
return $len;
}
sub read_json_data {
my $json_file = shift;
open my $fh, '<', $json_file or return (undef, undef);
my $content = do { local $/; <$fh> };
close $fh;
my $data = eval { decode_json($content) };
return (undef, undef) unless $data;
my $title = $data->{title};
my $ts = $data->{photoTakenTime}{timestamp} || $data->{creationTime}{timestamp};
return ($title, $ts);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment