Last active
January 10, 2024 22:30
-
-
Save feklee/f6113df3193997146c57ed56fbc6b745 to your computer and use it in GitHub Desktop.
Sorts media files into directories and timestamps the file names
This file contains 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
#!/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