Skip to content

Instantly share code, notes, and snippets.

@feklee
Last active December 28, 2025 15:45
Show Gist options
  • Select an option

  • Save feklee/f6113df3193997146c57ed56fbc6b745 to your computer and use it in GitHub Desktop.

Select an option

Save feklee/f6113df3193997146c57ed56fbc6b745 to your computer and use it in GitHub Desktop.
Sorts media files into directories and timestamps the file names
#!/usr/bin/perl
# Run without parameters to see usage.
# See also: organize_media
# Felix E. Klee <felix.klee@inka.de>
use strict;
use warnings;
use Image::ExifTool qw(:Public);
use File::Find;
use File::Path "make_path";
use File::Spec;
use Cwd qw(abs_path getcwd);
use File::Copy "move";
require Image::ExifTool::Geolocation;
my @media_extensions =
("jpg", "jpeg", "arw", "sr2", "nef", "dng", "png", "mp4", "wav");
# %b = basename without extension, %e = extension:
my @sidecar_formats =
(
"%b.xmp", # Adobe Camera Raw
"%b.%e.xmp", # Darktable
"%b.acr", # Adobe Camera Raw masks, etc.
"%bM01.XML", "%b.%eM02.KLV" # Sony video metadata
);
my $e = Image::ExifTool->new;
$e->Options(DateFormat => '%Y-%m-%d %H:%M:%S');
our $outdir;
our $script_dir;
sub is_supported_media {
my $filename = shift;
my $extensions_regex = join('|', @media_extensions);
return $filename =~ /\.($extensions_regex)$/i;
}
# Based on:
#
# https://github.com/exiftool/exiftool/blob/e2a8542f9c900eae70cb016f0ed410c1bfd4c1ac/config_files/local_time.config
sub media_creation_datetime_from_geolocation {
my $lat = $e->GetValue('GPSLatitude', 'ValueConv');
my $long = $e->GetValue('GPSLongitude', 'ValueConv');
return undef unless defined $lat and defined $long;
my ($location) = Image::ExifTool::Geolocation::Geolocate("$lat,$long");
my @time_fields = ('GPSDateTime', 'SubSecDateTimeOriginal',
'SubSecCreateDate', 'DateTimeOriginal',
'CreateDate');
my @times = map { $e->GetValue($_, 'ValueConv') } @time_fields;
my $time = shift @times;
return undef unless $time;
my $secs = Image::ExifTool::GetUnixTime($time, 1);
return undef unless $secs;
my @info = Image::ExifTool::Geolocation::GetEntry($$location[0]);
$e->Options(TimeZone => $info[5]);
my $local_time = Image::ExifTool::ConvertUnixTime($secs, 1);
return $e->ConvertDateTime($local_time);
}
sub media_creation_datetime_of_mp4_from_Sony_ZV1 {
my $info = shift;
my $creation_time;
# Exif data has time in UTC, but we want the local time.
my $offset = "$$info{TimeZone}:00";
Image::ExifTool::ShiftTime($creation_time, $offset);
return $$info{MediaCreateDate};
}
sub media_creation_datetime_of_mp4 {
my $info = shift;
my $manufacturer = $$info{DeviceManufacturer} // '';
my $model = $$info{DeviceModelName} // '';
my $creation_time;
if ($manufacturer eq "Sony" && $model eq "ZV-1") {
$creation_time = media_creation_datetime_of_mp4_from_Sony_ZV1($info);
} elsif (!defined $$info{TimeZone}) {
$creation_time = media_creation_datetime_from_geolocation($info);
}
return $creation_time;
}
# Some files, e.g. some screenshots, don't contain the "Date/Time
# Original" EXIF tag, but they may have the date / time encoded in the
# file name.
sub media_creation_datetime_from_filename {
my $filename = shift;
my $creation_time;
my $xiaomi_screenshot = # Chinese Redmi Note 12 5G
qr/^Screenshot_(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})/;
my $field_recorder_wav = # Android Field Recorder app
qr/^(\d{2})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})\.wav$/;
my $xperia_screen_recording = # Sony Xperia 10IV
qr/^screen-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})\.mp4/;
if ($filename =~ $xiaomi_screenshot) {
$creation_time = "$1-$2-$3 $4:$5:$6";
} elsif ($filename =~ $field_recorder_wav) {
$creation_time = "20$1-$2-$3 $4:$5:$6"; # assumes year >= 2000
} elsif ($filename =~ $xperia_screen_recording) {
$creation_time = "$1-$2-$3 $4:$5:$6";
}
return $creation_time;
}
sub media_creation_datetime {
my ($info, $filename) = @_;
my $creation_time;
if ($filename =~ /\.mp4$/i) {
$creation_time = media_creation_datetime_of_mp4($info);
} else {
$creation_time = $$info{DateTimeOriginal};
}
if (!defined $creation_time) {
$creation_time = media_creation_datetime_from_filename($filename);
}
return $creation_time;
}
sub date_from_datetime {
my $datetime = shift;
return $datetime =~ s/ .*//r;
}
sub time_from_datetime {
my $datetime = shift;
return $datetime =~ s/.* //r;
}
sub destdir_from_datetime {
my $datetime = shift;
return File::Spec->catfile($outdir, date_from_datetime($datetime));
}
sub ensure_dir_exists {
my $dir = shift;
unless (-d $dir) {
make_path($dir) or die "Cannot make directory $dir: $!";
}
}
sub formatted_path {
my $path = shift;
return File::Spec->abs2rel($path, $script_dir);
}
sub move_file {
my ($datetime, $destdir, $filename) = @_;
my $dest = File::Spec->catfile($destdir, $filename);
print "Moving $filename to ".formatted_path($dest)."\n";
if (-e $dest) {
die "File $filename already exists in $dest";
} else {
move($filename, $dest) or die "Cannot move file $filename: $!";
}
}
sub move_sidecars {
my ($datetime, $destdir, $filename) = @_;
my ($base, $ext) = $filename =~ /^(.+)\.([^.]*)$/;
if (!defined $base) { # no match
return;
}
foreach my $format (@sidecar_formats) {
my $sidecar_name = $format;
$sidecar_name =~ s/%b/$base/g;
$sidecar_name =~ s/%e/$ext/g;
if (-f $sidecar_name) {
move_file($datetime, $destdir, $sidecar_name);
}
}
}
sub process_file {
my $filename = shift;
if (-f $filename && is_supported_media($filename)) {
my $info = $e->ImageInfo($filename);
my $creation_datetime = media_creation_datetime($info, $filename);
if (defined($creation_datetime)) {
my $destdir = destdir_from_datetime($creation_datetime);
ensure_dir_exists($destdir);
move_file($creation_datetime, $destdir, $filename);
move_sidecars($creation_datetime, $destdir, $filename);
} else {
print "Cannot determine date/time: $filename\n";
}
}
}
unless (@ARGV == 2) {
die <<EOF
Usage: $0 INDIR OUTDIR
Iterates over media files in INDIR.
If creation date/time of a media file can be determined, it is moved
into a subdirectory of OUTDIR. The subdirectory is named by the local
creation date, i.e. the date in the location where the media file was
created.
Dates and times are in local time.
EOF
}
my $in_dir = $ARGV[0];
$outdir = abs_path($ARGV[1]);
$script_dir = getcwd();
my @files = <*>;
foreach my $file (@files) {
process_file($file);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment