Skip to content

Instantly share code, notes, and snippets.

@feklee
Last active January 10, 2024 22:30
Show Gist options
  • Save feklee/f6113df3193997146c57ed56fbc6b745 to your computer and use it in GitHub Desktop.
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.
# Felix E. Klee <[email protected]>
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";
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 $exifTool = Image::ExifTool->new;
$exifTool->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;
}
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") {
# Exif data has time in UTC, but we want the local time.
$creation_time = $$info{MediaCreateDate};
my $offset = "$$info{TimeZone}:00";
Image::ExifTool::ShiftTime($creation_time, $offset);
}
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 $s = filename_without_timestamp($filename);
if ($s =~ $xiaomi_screenshot) {
$creation_time = "$1-$2-$3 $4:$5:$6";
} elsif ($s =~ $field_recorder_wav) {
$creation_time = "20$1-$2-$3 $4:$5:$6"; # assumes year >= 2000
}
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: $!";
}
}
# Removes an existing time stamp (or what looks like an existing time
# stamp).
sub filename_without_timestamp {
my $filename = shift;
return $filename =~ s/^\d{6}_//r;
}
sub filename_with_timestamp {
my ($datetime, $filename) = @_;
my $time = time_from_datetime($datetime);
my $new_timestamp = $time =~ s/://gr;
return "${new_timestamp}_".filename_without_timestamp($filename);
}
sub formatted_path {
my $path = shift;
return File::Spec->abs2rel($path, $script_dir);
}
sub move_file_with_timestamp {
my ($datetime, $destdir, $filename) = @_;
my $new_filename = filename_with_timestamp($datetime, $filename);
my $dest = File::Spec->catfile($destdir, $new_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_with_timestamp {
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_with_timestamp($datetime, $destdir, $sidecar_name);
}
}
}
sub process_file {
my $filename = $_;
if (-f $filename && is_supported_media($filename)) {
my $info = $exifTool->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_with_timestamp($creation_datetime, $destdir, $filename);
move_sidecars_with_timestamp($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 subfolder of OUTDIR. The subfolder is named by the creation
date. The filename is prefixed with a timestamp representing the
creation time. Existing timestamps in the filename are replaced.
Sidecar files are moved and timestamped with the media files.
Dates and times are in local time.
EOF
}
my $in_dir = $ARGV[0];
$outdir = abs_path($ARGV[1]);
$script_dir = getcwd();
find(\&process_file, $in_dir);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment