Last active
December 28, 2025 15:45
-
-
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 hidden or 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. | |
| # 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