#!/opt/local/bin/perl -w # Copyright © 2011-2022 Jamie Zawinski # # Permission to use, copy, modify, distribute, and sell this software and its # documentation for any purpose is hereby granted without fee, provided that # the above copyright notice appear in all copies and that both that # copyright notice and this permission notice appear in supporting # documentation. No representations are made about the suitability of this # software for any purpose. It is provided "as is" without express or # implied warranty. # # For doing a partial one-way sync of iTunes libraries, using rsync, ssh # and AppleScript. # # Usage: # # itunes-rsync.pl -v 'Local Playlist Name' remote-host-name # # It does the following: # # - Get a list of the tracks in the local iTunes playlist. # # - Run rsync to push those files to the remote machine, deleting any files # that are on the remote machine but are not in the local playlist. # # - Get the local iTunes metadata about the changed tracks via AppleScript. # # - Import newly-added files into the remote iTunes. # # - Update the remote metadata of any changed files to match the local # metadata. # # Updating stars: # # With the --stars-only option, it will update the star ratings and # checkedness of *every* track. This will take a long time, but might # sometimes be necessary, since changing the rating of a track in # iTunes doesn't change the file's write date, so there's no way to # tell when that change has happened. # # Comparing: # # The --diff option will compare local and remote metadata. It's slow. # Like, 40+ minutes slow. And sometimes it will time out and claim there # are no differences. # # Caveats: # # - Error recovery is basically nonexistent. # # - If you make any changes to the name of an artist or track, including # capitalization, it will delete and re-transfer the files (since rsync # has no concept of case-insensitive file systems). # # - The "Date Added" attribute in the remote iTunes will be nonsense. # # - This script runs rsync and ssh four times total, so to avoid typing # your password over and over, set up ssh keys/agents for passwordless # login. # # - It assumes that both machines have their iTunes libraries in the # default place. (Edit $music_dir, or use symlinks.) # # Created: 2-May-2011. require 5; use diagnostics; use strict; use POSIX; use IPC::Open3; use File::Path ('make_path'); use Encode; use Unicode::Normalize; BEGIN { eval 'use Mac::iTunes::Library::XML;'; } # Optional my $progname = $0; $progname =~ s@.*/@@g; my ($version) = ('$Revision: 1.75 $' =~ m/\s(\d[.\d]+)\s/s); my $verbose = 1; my $debug_p = 0; # Surely there is some way to ask iTunes what this path is via AppleScript, # but I have not found it. my $music_dir = 'Music/iTunes/iTunes Media/Music/'; # Update: I guess this would work, but what a fragile crock of shit! # # lib=`defaults read com.apple.iapps iTunesRecentDatabases | # sed -n 's@^.*"file://localhost\(/.*\)".*$@\1@p' | # sed 's/%20/ /g' | head -1` # dir=`cat "$lib" | # sed -n s'@^.*>Music Folder<.*>file://localhost\(/[^<>]*/\).*@\1Music/@p' | # sed 's/%20/ /g' | head -1` my @rm_f = (); my $rm_rf = undef; END { my $exit = $?; unlink @rm_f if (@rm_f); system ("rm", "-rf", $rm_rf) if $rm_rf; $? = $exit; # Don't clobber this script's exit code. } sub signal_cleanup() { exit (1); } # This causes END{} to run. $SIG{TERM} = \&signal_cleanup; # kill $SIG{INT} = \&signal_cleanup; # shell ^C $SIG{QUIT} = \&signal_cleanup; # shell ^| $SIG{KILL} = \&signal_cleanup; # nope $SIG{ABRT} = \&signal_cleanup; $SIG{HUP} = \&signal_cleanup; sub url_unquote($) { my ($url) = @_; #$url =~ s/[+]/ /g; $url =~ s/%([a-z0-9]{2})/chr(hex($1))/ige; return $url; } sub safe_system(@) { my @cmd = @_; system (@cmd); my $exit_value = $? >> 8; my $signal_num = $? & 127; my $dumped_core = $? & 128; error ("$cmd[0]: core dumped!") if ($dumped_core); error ("$cmd[0]: signal $signal_num!") if ($signal_num); error ("$cmd[0]: exited with $exit_value!") if ($exit_value); } # Returns width, height, duration and sar of the audio or video file. # Error if file is unreadable. # sub video_size($$) { my ($name, $file) = @_; my @cmd = ('ffmpeg', '-hide_banner', '-loglevel', 'info', # so it prints duration, etc. '-i', $file); print STDERR "$progname: size: exec: " . join(" ", @cmd) . "\n" if ($verbose > 3); my $result = ''; my ($inf, $outf, $errf); $errf = Symbol::gensym; my $pid = eval { open3 ($inf, $outf, $errf, @cmd) }; error ("unable to exec $cmd[0]: $!") unless $pid; close ($inf); close ($outf); my $errs = ''; while (<$errf>) { $errs .= $_; } waitpid ($pid, 0); close ($errf); print STDERR $errs if ($verbose > 4); my ($h, $m, $s, $ss) = ($errs =~ m/Duration: (\d\d):(\d\d):(\d\d).(\d\d),/s); error ("$name: no duration: $errs") unless defined($ss); my $dur = $h*60*60 + $m*60 + $s + $ss/100; # $dur = sprintf("%.1f", $dur) + 0; # one significant digit my ($line) = ($errs =~ m/^\s*Stream [^\n]* Video: ([^\n]+)/m); $line = '' unless $line; my ($fw, $fh) = ($line =~ m/ (\d\d\d?\d?)x(\d\d\d?\d?)[, ]/s); # SAR indicates non-square pixels in storage (anamorphic video) my ($sar_w, $sar_h) = ($line =~ m/\bSAR (\d+):(\d+)\b/s); my $sar = $sar_h ? $sar_w / $sar_h : 1; if ($sar && $sar != 1) { $fw = int(($fw * $sar) + 0.5); $fw++ if ($fw & 1); # h264 requires even-numbered dimensions. } ($fw, $fh) = ($fh, $fw) if ($errs =~ m/ rotate *: *-?90\b/s); my $audio_p = ($errs =~ m/Stream .* Audio: /m); return ($fw, $fh, $dur, $audio_p, $sar); } sub movie_volume($;$) { my ($file, $dur) = @_; (undef, undef, $dur) = video_size ($file, $file) unless defined ($dur); # caller already knew duration # When checking volume, ignore first and last 1/4th of the file, to # ignore leading / trailing quiet parts. # But don't do this for really short files. my $astart = $dur * 0.25; my $aend = $dur * 0.75; my $alen = $aend - $astart; if ($alen < 45) { $astart = 0; $alen = $dur; } my $db; if (1) { # Extract the audio track from the video file as a 16 bit 44.1kHz WAV # then run it through LAME to get the volume adjustment. my $tmpfile = sprintf ("%s/itrs.%08x.wav", ($ENV{TMPDIR} ? $ENV{TMPDIR} : "/tmp"), rand(0xFFFFFFFF)); push @rm_f, $tmpfile; my @cmd = ('ffmpeg', '-hide_banner', '-loglevel', 'error', '-ss', $astart, # -ss before -i is way faster '-i', $file, '-t', $alen, '-vn', '-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2', $tmpfile); my ($inf, $outf, $errf); $errf = Symbol::gensym; print STDERR "$progname: size: exec: " . join(" ", @cmd) . "\n" if ($verbose > 3); my $pid = eval { open3 ($inf, $outf, $errf, @cmd) }; error ("unable to exec $cmd[0]: $!") unless $pid; close ($inf); close ($outf); my $errs = ''; while (<$errf>) { $errs .= $_; } waitpid ($pid, 0); close ($errf); # Find the volume adjustment for that tmp WAV file. We can't do this with # pipe instead of a tmp file because ffmpeg writes bogus WAVs to stdout. @cmd = ('lame', '--replaygain-accurate', '-f', $tmpfile, '/dev/null'); $errf = Symbol::gensym; print STDERR "$progname: size: exec: " . join(" ", @cmd) . "\n" if ($verbose > 3); $pid = eval { open3 ($inf, $outf, $errf, @cmd) }; error ("unable to exec $cmd[0]: $!") unless $pid; close ($inf); close ($outf); $errs = ''; while (<$errf>) { $errs .= $_; } waitpid ($pid, 0); close ($errf); ($db) = ($errs =~ m/^ReplayGain: \s+ \+* ( -* [\d.]+ ) \s* dB$/mx); unlink ($tmpfile); } else { # This way is faster, and gives approximately the same result as lame. # (It might be rounding differently: -9.7 dB instead of -9.8 dB.) # Nope, ffmpeg is terrible at this. # E.g. on https://youtu.be/1P-mvWSAyNQ # LAME reports -1.7 dB but # ffmpeg reports -16.9 dB. my @cmd = ('ffmpeg', '-hide_banner', '-loglevel', 'info', # to print volumedetect '-ss', $astart, # -ss before -i is way faster '-i', $file, '-t', $alen, '-filter:a', 'volumedetect', '-f', 'null', '/dev/null'); my ($inf, $outf, $errf); $errf = Symbol::gensym; print STDERR "$progname: size: exec: " . join(" ", @cmd) . "\n" if ($verbose > 3); my $pid = eval { open3 ($inf, $outf, $errf, @cmd) }; error ("unable to exec $cmd[0]: $!") unless $pid; close ($inf); close ($outf); my $errs = ''; while (<$errf>) { $errs .= $_; } waitpid ($pid, 0); close ($errf); ($db) = ($errs =~ m/ mean_volume: \s+ ( [-+]?\d+[\d.]* ) \s* dB $/mx); } error ("$file: unparsable dB") unless $db; # For relative volume x, dB value is 20*log[10](x) # if y=log[b](x) then x=b^y # so db=20*log[10](x) # and x=10^(db/20) # my $pct = 100 * (10 ** ($db / 20.0)) - 100; return $pct; } # Issue a warning if there is a file in the Music directory that is not # listed in the iTunes library. Sometimes iTunes just randomly loses its # pointer to tracks and they have to be reimported, because iTunes is a # very bad program. # sub validate_local_files($) { my ($lib) = @_; print STDERR "$progname: comparing library to disk...\n" if $verbose; my %lib_files; my %real_files; my %Lib_files; my %Real_files; my %items = $lib->items(); while (my ($artist, $tracks) = each %items) { while (my ($name, $sitems) = each %$tracks) { foreach my $track (@$sitems) { # Do something here to every item in the library my $file = $track->location(); $file =~ s@^file://@@s; $file = url_unquote($file); $file =~ s@^/Users/[^/]+/$music_dir@@si; my $key = Encode::decode ('utf8', $file); # Pack UTF-8 to wide chars # Note that HFS stores file names as NFKD (accent as separate glyph). # APFS allows NFKD, but might also allow NFKC? $key = Unicode::Normalize::normalize ('NFKC', $key); $lib_files{lc($key)} = $file; $Lib_files{$key} = $file; } } } my $d = $ENV{HOME} . "/$music_dir"; opendir (my $dir, $d) || error ("$d: $!"); foreach my $artist (readdir ($dir)) { next if ($artist =~ m/^[. ]/s); my $d = "$d$artist"; next unless -d $d; opendir (my $adir, $d) || error ("$d: $!"); foreach my $album (sort readdir ($adir)) { next if ($album =~ m/^\./s); my $d = "$d/$album"; next unless -d $d; next if ($d =~ m@Prince/DNA Lounge \(2013\)@s); # Kludge opendir (my $bdir, $d) || error ("$d: $!"); foreach my $track (sort readdir ($bdir)) { next if ($track =~ m/^\./s); my $file = "$d/$track"; next if -d $file; next if ($file =~ m/\.(txt|pdf)$/s); $file =~ s@^/Users/[^/]+/$music_dir@@si; my $key = Encode::decode ('utf8', $file); # Pack UTF-8 to wide chars # Note that HFS stores file names as NFKD (accent as separate glyph). # APFS allows NFKD, but might also allow NFKC? $key = Unicode::Normalize::normalize ('NFKC', $key); $real_files{lc($key)} = $file; $Real_files{$key} = $file; } closedir $bdir; } closedir $adir; } closedir $dir; foreach my $f (keys %lib_files) { delete $real_files{$f}; } my $n = scalar(keys %real_files); if ($n) { print STDERR "\n$progname: WARNING: $n existing files are" . " missing from iTunes:\n\n"; my $i = 0; foreach my $file (sort keys %real_files) { $file = $real_files{$file}; $file = Encode::decode ('utf8', $file); # Pack UTF-8 to wide chars print STDERR "$file\n" if ($i++ < 500); } print STDERR "\n"; print STDERR "$progname: If they aren't actually redundant files,\n"; print STDERR "$progname: you should probably re-import them.\n"; print STDERR "\n"; } foreach my $f (keys %lib_files) { my $f2 = $lib_files{$f}; $f2 = Encode::decode ('utf8', $f2); # Pack UTF-8 to wide chars # Note that HFS stores file names as NFKD (accent as separate glyph). # APFS allows NFKD, but might also allow NFKC? $f2 = Unicode::Normalize::normalize ('NFKC', $f2); delete $Real_files{$f2}; } $n = scalar(keys %Real_files) - $n; if ($n) { print STDERR "\n$progname: WARNING: $n existing files " . "differ by case or unicrud in iTunes:\n\n"; my $i = 0; foreach my $file (sort keys %Real_files) { my $file2 = $Real_files{$file}; $file2 = Encode::decode ('utf8', $file2); # Pack UTF-8 to wide chars $file2 = $lib_files{lc($file2)}; next unless defined($file2); # One of the missing ones, noted above. $file2 = Encode::decode ('utf8', $file2); # Pack UTF-8 to wide chars print STDERR "$file\n$file2\n\n" if ($i++ < 500); } print STDERR "\n"; print STDERR "$progname: Making a trivial edit to the track name\n"; print STDERR "$progname: in iTunes then changing it back may fix it.\n"; print STDERR "\n"; } } # Sometimes, iTunes doesn't flush its metadata down into the files. # This uses ffmpeg to ensure that the proper metadata is there. # sub repair_metadata($@) { my ($lib, @files) = @_; if (@files >= 1000) { print STDERR "$progname: skipping metadata validation\n" if $verbose; return; } print STDERR "$progname: validating metadata in files...\n" if $verbose; my %path_to_track; my $home = $ENV{HOME}; my %items = $lib->items(); while (my ($artist, $tracks) = each %items) { while (my ($name, $sitems) = each %$tracks) { foreach my $track (@$sitems) { my $file = $track->location(); $file =~ s@^file://@@s; $file = url_unquote($file); $file = Encode::decode ('utf8', $file); # Pack UTF-8 to wide chars $path_to_track{$file} = $track; } } } # @files = sort keys (%path_to_track); # DEBUG: To do the entire library foreach my $file (@files) { $file = $ENV{HOME} . "/$music_dir$file" unless ($file =~ m@^/@s); my $track = $path_to_track{$file}; error ("not in library: $file") unless $track; repair_track_metadata ($lib, $file, $track); } } sub repair_track_metadata($$$) { my ($lib, $file, $track) = @_; print STDERR "$progname: checking $file\n" if ($verbose > 2); # Read the metadata from the iTunes library. my %md; $md{artist} = $track->artist() || ''; $md{composer} = $track->composer() || ''; $md{album} = $track->album() || ''; $md{album_artist} = $track->albumArtist() || ''; $md{track} = ($track->trackNumber() && $track->trackCount() ? $track->trackNumber() . '/' . $track->trackCount() : $track->trackNumber() || ''); $md{title} = $track->name() || ''; $md{genre} = $track->genre() || ''; $md{date} = $track->year() || ''; $md{comment} = $track->comments() || ''; #$md{rating} = $track->rating() || ''; # iTunes never saves this my $tname = $md{artist} . " - " . $md{title}; my $dir = $ENV{TMPDIR} ? $ENV{TMPDIR} : "/tmp"; my $name = rand(0xFFFFFFFF); my ($ext) = ($file =~ m@\.([^/.]+)$@s); my $out = sprintf ("%s/%s.%s", $dir, $name, $ext); my $md = sprintf ("%s/%s.%s", $dir, $name, 'txt'); my $vidp = ($ext =~ m/^(mp4|m4a|m4p|m4v|mov|mpg|mpeg)$/si); push @rm_f, $out; push @rm_f, $md; # Make sure the disk file is valid. my ($file_width, $file_height, $file_duration); eval { ($file_width, $file_height, $file_duration) = video_size ($file, $file); }; error ("file is corrupted: $@") if ($@); # Read the metadata from the disk file. #### It looks like ffmpeg is bad at this. I regularly see files where #### ffmpeg can't see the TMPO tag but MP4::Info can. So maybe we #### should be using that instead. my @cmd0 = ('ffmpeg', '-hide_banner', '-loglevel', 'error', '-i', $file); my @cmd = (@cmd0, '-f', 'ffmetadata', $md); my ($inf, $outf, $errf); $errf = Symbol::gensym; print STDERR "exec: " . join(' ', @cmd) . "\n" if ($verbose > 3); my $pid = eval { open3 ($inf, $outf, $errf, @cmd) }; error ("unable to exec $cmd[0]: $!") unless $pid; close ($inf); close ($outf); my $errs = ''; while (<$errf>) { $errs .= $_; } waitpid ($pid, 0); close ($errf); print STDERR $errs if ($verbose > 4); # Parse the metadata file that ffmpeg wrote. open ($inf, '<:utf8', $md) || error ("$file: metadata unreadable: $!"); print STDERR "$progname: reading $md\n" if ($verbose > 3); my %omd; my $s = <$inf>; error ("unparsable metadata file: $s") unless ($s =~ m/^;FFMETADATA1\n$/s); while ($s = <$inf>) { last if ($s =~ m/^\[CHAPTER\]\n/s); # so well documented... my ($key, $val) = ($s =~ m/^([^=\s]+)=([^\n]*)\n$/s); error ("unparsable metadata line: $s: $file") unless defined($val); # A \ and end of line is continuation. And at least # and = are also # backslashed. It's totally undocumented! Also I hope it's UTF8... # if ($val =~ s/\\$//s) { while (1) { my $val2 = <$inf>; last unless defined($val2); chomp($val2); if ($val2 =~ s/\\$//s) { $val .= "\n" . $val2; } else { $val .= "\n" . $val2; last; } } } $val =~ s/\\(.)/$1/gs; $omd{$key} = $val; } close $inf; unlink $md; # Note missing BPM print STDERR "$progname: WARNING: no BPM: $file\n" if (! $omd{initialkey}); print STDERR "\n$progname: WARNING: BPM comment: \"" . $omd{comment} . "\" - $file\n\n" if (($omd{comment} || '') =~ m/^\d+[A-Z]/s); # Compare the iTunes and file metadata. my $diff = 0; my $desc = ''; foreach my $key (keys %md) { my $new = $md{$key} || ''; my $old = $omd{$key} || ''; if ($new eq $old) { print STDERR "$tname: OK: $key \"$old\"\n" if ($verbose > 3); } else { # Apparently ffmpeg *writes* the 'track' properly, but can't *read* it! # So always include that when writing, but don't consider it a diff. # # Also apparently sometimes it can't write 'composer' and 'album_artist'. # Nice. # $diff++ unless ($key =~ m/^(track|composer|album_artist)$/s); push @cmd0, ('-metadata', "$key=$new"); $desc .= "$progname: $key" . ($old ? " $old => $new" : ": $new") . "\n"; } } # Check that the volume adjustment in iTunes is correct. # if (($track->rating() || 0) >= 40) { my $itunes_vol = $track->{'Volume Adjustment'} || 0; my $file_vol = movie_volume ($file, $file_duration); # In the XML file, Volume Adjustment has the range [-255, 255]. # In AppleScript, it has the range [-100, 100]. # Because fuck you that's why. # $itunes_vol = ceil($itunes_vol * 100/256); my $diff_pct = abs ($itunes_vol - $file_vol); if ($diff_pct > 15) { # Allow some slack print STDERR "\n$progname: WARNING: volume mismatch:" . " $itunes_vol should be " . int($file_vol) . ": $file\n\n"; } } return unless $diff; # Write a new file with the changed metadata. There's no way to update # it in place. @cmd = (@cmd0, '-acodec', 'copy', ($vidp ? ('-vcodec', 'copy') : ()), $out); print STDERR "exec: " . join(' ', @cmd) . "\n" if ($verbose > 3); $pid = eval { open3 ($inf, $outf, $errf, @cmd) }; error ("unable to exec $cmd[0]: $!") unless $pid; close ($inf); close ($outf); $errs = ''; while (<$errf>) { $errs .= $_; } waitpid ($pid, 0); close ($errf); print STDERR $errs if ($verbose > 4); my $osize = ((stat($file))[7]) || 0; my $nsize = ((stat($out))[7]) || 0; my $maxb = 8 * 1024 * 1024; # A change this large can be normal?? my $maxp = 0.10; # Allow it as long as it's not too large a pct. my $err = undef; my $bdiff = abs ($nsize - $osize); my $pdiff = abs (1 - ($nsize / $osize)); if ($pdiff > $maxp || $bdiff > $maxb) { $err = sprintf ("writing metadata: file size would have changed by" . " %d%% (%d KB): %d KB -> %d KB: %s \n$desc", $pdiff * 100, $bdiff / 1024, $osize / 1024, $nsize / 1024, $file); } #error ($err) if $err; print STDERR "$progname: WARNING: $err\n" if $err; # Make sure ffmpeg didn't write a corrupted file. my ($w2, $h2, $dur2); eval { ($w2, $h2, $dur2) = video_size ($file, $out); }; error ("would have corrupted the file: $@") if ($@); if (($file_width || 0) != ($w2 || 0) || ($file_height || 0) != ($h2 || 0)) { error ("size would have changed: $file: " . ($file_width ? "${file_width}x$file_height, " : "") . " => " . ($w2 ? "${w2}x$h2, " : "") . "\n"); } if (abs (int($file_duration) - int($dur2)) > 4) { error ("duration would have changed: $file: " . sprintf("%d:%02d", $file_duration / 60, $file_duration % 60) . " => " . sprintf("%d:%02d", int($dur2) / 60, int($dur2) % 60) . "\n"); } if ($debug_p || $err) { print STDERR "$progname: not replacing $file\n$desc"; } else { print STDERR "$progname: updating $file\n$desc" if ($verbose); safe_system ("cp", $out, $file); # Use cp to preserve permissions. } unlink ($out, $md); } # Creates a directory tree populated with symbolic links to the # given files. Returns the name of that tmp directory. # sub link_farm(@) { my (@files) = @_; my $dir = sprintf ("%s/itrs.%08x", ($ENV{TMPDIR} ? $ENV{TMPDIR} : "/tmp"), rand(0xFFFFFFFF)); $dir =~ s@//+@/@gs; $rm_rf = $dir; # nuke it even at abnormal exits. system ("rm", "-rf", $rm_rf); print STDERR "$progname: creating link farm: $dir\n" if ($verbose > 1); umask (022); foreach my $ofile (@files) { next unless -f $ofile; my $file = $ofile; $file =~ s@^/Users/[^/]+/$music_dir@@si; my ($dir2) = ($file =~ m@^(.*)/[^/]+$@si); error ("unparsable: $file") unless $dir2; my $td = "$dir/$dir2"; make_path ($td); symlink ($ofile, "$dir/$file") || 0;#### error ("ln -s '$ofile' '$dir/$file': $!"); } return $dir; } my @all_fields = ('name', 'artist', 'album artist', 'album', 'composer', 'comment', 'genre', 'year', 'track number', 'track count', 'bpm', 'compilation', 'volume adjustment', 'video kind', 'enabled', 'rating', 'start', 'finish', 'bookmarkable', 'shufflable'); my $forever = (60 * 60 * 8) . " seconds"; # Talks to the local iTunes and returns the contents of a playlist # as a list of the underlying file names (absolute paths). # sub get_playlist($) { my ($playlist) = @_; print STDERR "$progname: getting playlist \"$playlist\"" . " from local iTunes...\n" if ($verbose); # Using Mac::iTunes::Library::XML is slower than interrogating iTunes # for the playlist, but sometimes the AppleScript way HANGS FOREVER # because iTunes is VERY VERY SHITTY. # Written by jwzlyrics/itunesxml: my $file = $ENV{HOME} . '/Music/iTunes/library.xml'; # Regenerate that file if it is more than a minute old (takes about 35 sec). my @st = stat($file); my $age = time() - ($st[9] || 0); if ($age > 60 && !$debug_p) { my @cmd = ('itunesxml', $file); print STDERR "$progname: exec: " . join(' ', @cmd) . "\n" if ($verbose); safe_system (@cmd); } # No longer created by iTunes on macOS 10.15 or later: $file = $ENV{HOME} . '/Music/iTunes/iTunes Music Library.xml' if (! -f $file); my $lib = undef; eval { print STDERR "$progname: reading $file\n" if $verbose; $lib = Mac::iTunes::Library::XML->parse ($file); }; if ($lib) { validate_local_files($lib); my @L; my %ps = $lib->playlists(); foreach my $pid (keys %ps) { my @errors; my $p = $ps{$pid}; next unless ($p->name() eq $playlist); foreach my $i ($p->items()) { my $loc = $i->location(); my $vidp = $i->{'Music Video'}; my $path = $loc; $path =~ s@^file://@@s; $path = url_unquote($path); $path = Encode::decode ('utf8', $path); # Pack UTF-8 to wide chars # iTunes sometimes decides to randomly switch a "Music Video" to # a "Home Movie", which loses artist/album/year/genre/rating info, # and sometimes renames the file. HOORAY FOR iTUNES. # if (!!$vidp != !!($path =~ m/\.(mp4|m4v|mov|avi)$/si)) { push @errors, "The \"Music Video\" flag is set wrong: $path"; } if ($path =~ m@\.Trash@s) { push @errors, "Existing track is in the trash: $path"; } push @L, $path; if (! -f $path) { my $path2 = $path; $path2 =~ s@^.*/@@s; $path2 = $ENV{HOME} . '/Music/iTunes/iTunes Media/Home Videos/' . $path2; if (-f $path2) { $path =~ s@^.*/([^/]+/[^/]+/[^/]+)$@$1@s; push @errors, "iTunes lost its mind and put \"$path\" into \"Home Videos\""; } else { push @errors, "file does not exist: $path"; } } } if (@errors) { @errors = @errors[0 .. 100] if (@errors > 100); my $err = join("\n$progname: ", @errors); if ($err =~ m/Home Videos/) { $err .= "\n\n$progname: doing \"Get Info\" on each of " . "those tracks may fix it.\n\n"; } #### error ($err); print STDERR "### $err\n"; } # repair_metadata ($lib, sort @L); # DEBUG: to check entire playlist return ($lib, @L) ;#### unless @errors; } error ("no such playlist: $playlist"); } else { print STDERR "$progname: $file failed: $@. Using AppleScript...\n"; my $script = ("with timeout of $forever\n" . " tell application \"Music\"\n" . " set L to the location of every file track" . " of playlist \"$playlist\"\n" . " end tell\n" . " set OUTPUT to \"\"\n" . " repeat with P in L\n" . " set OUTPUT to OUTPUT & (the POSIX path of P)" . " & \"\\n\"\n" . " end repeat\n" . "end timeout\n" . "OUTPUT\n"); my @lines = (); { my @cmd = ('osascript'); my ($in, $out, $err); $err = Symbol::gensym; print STDERR "exec: " . join(' ', @cmd) . " <<\n$script\n" if ($verbose > 3); my $pid = open3 ($in, $out, $err, @cmd); $script = Encode::encode ('utf8', $script); # Unpack wide chars to UTF-8 print $in $script; close ($in); while (<$out>) { push @lines, $_; } waitpid ($pid, 0); } my @L; foreach (@lines) { chomp $_; next unless $_; push @L, $_; } print STDERR "$progname: " . ($#L+1) . " tracks in playlist.\n" if ($verbose); return (undef, @L); } } # Given a remote host (or "user@host"), runs rsync to push the given # iTunes files into the remote iTunes music directory, then updates # the database inside the remote iTunes. # sub sync_files($$$$$$$) { my ($host, $stars_only, $files_only, $force_p, $progress_p, $bwlimit, $playlist) = @_; my ($lib, @files) = get_playlist ($playlist); my $link_farm = link_farm (@files); my $md2 = $music_dir; $md2 =~ s@([^-_.,a-z\d/])@\\$1@gsi; $link_farm .= '/' unless ($link_farm =~ m@/$@s); $md2 .= '/' unless ($md2 =~ m@/$@s); # Ok, get this shit. We have to run rsync three times. # # 1: "rsync --dry-run" to compute the list of changed files. # Without that, we don't know which metadata to extract from # the local iTunes and transmit to the remote. # # 2: "rsync" to actually transfer the files. # # 2b: Then we tell the remote iTunes to update the metadata. # Oops, a side-effect of this is that iTunes writes to the # files! So the write dates change... AND SO DO THE CHECKSUMS. # I have no idea why the sums change, but they do! # # 3: So after updating the metadata, we rsync again, so that the # remote iTunes database has the right data; and the files have # the same data on both machines. This is the *same* data that # is in the iTunes DB, but as-written-by local iTunes instead of # as-written-by remote iTunes. # # What a steaming load of shit. # # Oh, I also tried simply not updating the metadata in the remote iTunes # if it hadn't changed, but that didn't work either, possibly due to # rounding errors? Possibly due to other AppleScript idiocy? You read # "volume adjustment = 48" on local host, write "48" into remote host, # read it back, and now it's 49 instead. Who the fuck knows. # # It would be possible to combine steps 1 and 2a (just do the first sync # without a --dry-run first, and parse the output of that rsync) but then # we couldn't issue the warning, "lots of files to be deleted, use # --force if you're sure" warning, # # Fuck AppleScript and the iTunes it rode in on. my $remote = ($host =~ m@[/:]@s ? "$host$md2" : "$host:$md2"); $remote =~ s@\\@@gs; #### if ($host =~ m@/@s && $host !~ m/\@/s); my @cmd = ("rsync", "-aOLh", "--delete-during", "--partial-dir", ".rsync-tmp", "--progress", # Default block size is file size / 10000, min 700, max 16k, so # for a 100MB file that's about 10KB. That's much larger than # both an EXIF block, and a typical MPEG frame, so let's try and # shrink that. There is about 20 bytes of checksum overhead per # block. For a 100MB file at BS=512, that's 4MB overhead. # # So if there's only one frame change, the default settings win # so long as that frame is less than 10KB. # "--block-size", "512", # Maybe faster if files were fully re-encoded, rather than just # having had metadata updates? With the default block size # of 10000 blocks per file, this saves about 200KB. But with # BS=512, this saves 4MB per file. # # "--whole-file", # FFFFFFuuuuuuu.... without this, rsync from macOS 10.14.6 APFS # to 10.11.6 HFS confuses iacute \x{ed} with 2-byte i\x{301}. # Without this, it thinks they're two different files. # It appears that HFS insists on storing as the 2-byte version. # ("UTF8-MAC" is a synonym for "UTF-8 NFD"). # # And for some reason HFS wasn't able to properly represent # astral plane emoji in file names. I got tired of fucking around # with this and upgraded the relevant file systems from HFS to # AFPS, so this isn't needed any more. YMMV. # # "--iconv=UTF-8,UTF8-MAC", "$link_farm", "$remote"); push @cmd, ("--bwlimit", $bwlimit) if ($bwlimit); my %files; foreach my $f (@files) { my $f2 = $f; $f2 =~ s@^/Users/[^/]+/$music_dir@@si; $files{$f2} = 1; } ############################################################# Pass 1! print STDERR "$progname: getting list of changed files...\n" if ($verbose); my @cmd2 = @cmd; push @cmd2, ("-v", "--dry-run"); my $cmd2 = ''; foreach (@cmd2) { $_ = "\"$_\"" if m/ /s; $cmd2 .= " $_"; } print STDERR "$progname: $cmd2\n" if ($verbose > 1); my $result = `( $cmd2 ) 2>&1`; my @changes = (); error ($result) if ($result =~ m/rsync error: unexplained error/si); # #### To manually force an update of the metadata of particular files, # put 'em here. Or just "touch" them. # # $result .= Encode::decode ('utf8',''); # Pack UTF-8 to wide chars if ($verbose > 2) { my $s = $result; $s =~ s/^/\t/gm; print STDERR "$s\n"; } my %case; my $case_err = 0; my @deleting = (); foreach my $line (split (/[\r\n]+/, $result)) { next unless $line; next if ($line =~ m/^building file list/s); next if ($line =~ m/^sending incremental file list/s); next if ($line =~ m/^sent [\d,.]+[KMG]? bytes/s); next if ($line =~ m/^total size is [\d,.]+[KMG]? /s); next if ($line =~ m/^Killed by signal 1\./s); next if ($line =~ m@/$@s); next if ($line =~ m@\.DS_Store$@s); if ($line =~ m/^deleting +(.*)/s) { $line = $1; push @deleting, $line; my ($a) = ($line =~ m@^([^/]+/[^/]+)@s); $case{lc($a)} = $a; next; } $line =~ s@[\\][#](\d\d\d)@{ chr(oct($1)) }@gsex; # Unicrud $line = Encode::decode ('utf8', $line); # Pack UTF-8 to wide chars # Note that HFS stores file names as NFKD (accent as separate glyph). # APFS allows NFKD, but might also allow NFKC? # This does not appear to be needed here: # $line = Unicode::Normalize::normalize ('NFKC', $line); if ($files{$line}) { push @changes, $line; # Since rsync doesn't understand case-insensitive file systems, # a change in capitalization of a band name will cause it to # delete and re-send the entire directory. Detect that before # it happens, and warn. my ($a) = ($line =~ m@^([^/]+/[^/]+)@s); my $o = $case{lc($a)}; if ($o && $a ne $o) { print STDERR "$progname: WARNING: case change:\n\n\t\t \"$o\"\n" . "\t vs: \"$a\"\n"; $case_err = 1; } } else { print STDERR "$progname: not a file: $line\n"; #foreach my $k (sort keys %files) { # print STDERR "#[$k]\n" if ($k =~ m/Video EP/si); #} #use Data::Dumper;print STDERR Dumper(\%files); exit(1) if ($debug_p); } } my $n = $#deleting+1; if(0){ #### if ($n >= 10 && !$force_p) { print STDERR "\n$progname: $n files to be deleted!" . " Use --force if you're sure!\n\n"; foreach my $d (sort @deleting) { print STDERR "\t$d\n"; } print STDERR "\n"; exit 1; } } if ($case_err && !$force_p) { print STDERR "$progname: Use --force if you're sure!\n"; exit 1; } my $nchanges = $#changes+1 + $n; print STDERR "$progname: $nchanges changed files.\n" if ($verbose); if ($nchanges < 0 && !$debug_p && !$stars_only) { exit(0); } repair_metadata ($lib, @changes) if @changes; ############################################################# Pass 2! push @cmd, ($verbose ? "-vi" : "-q"); push @cmd, '--progress' if ($progress_p); push @cmd, '--info=progress2' if ($progress_p > 1); push @cmd, "--dry-run" if ($debug_p); if ($debug_p) { print STDERR "$progname: not running: " . join(' ', @cmd) . "\n"; } elsif ($nchanges > 0) { print STDERR "$progname: " . join(' ', @cmd) . "\n" if ($verbose > 1); safe_system (@cmd); } if ($files_only) { print STDERR "\n$progname: done! iTunes skipped." . " $nchanges files updated.\n\n" if ($verbose); return; } ############################################################# Pass 2B! @changes = sort (keys (%files)) if ($stars_only); # do them all. my $metadata = sync_metadata ($host, $stars_only, @changes); ############################################################# Pass 3! print STDERR "$progname: metadata updated, syncing files again!\n" if ($verbose); if ($debug_p) { print STDERR "$progname: not running: " . join(' ', @cmd) . "\n"; } else { print STDERR "$progname: " . join(' ', @cmd) . "\n" if ($verbose > 1); safe_system (@cmd); } print STDERR "\n$progname: done! $nchanges files updated.\n\n" if ($verbose); } # Given some AppleScript code, runs it on the remote host, via ssh. # sub run_remote_applescript($$) { my ($host, $script) = @_; my $remote = "ssh -xTC $host osascript"; if ($debug_p) { print STDERR "$progname: not running: $remote\n"; } else { print STDERR "$progname: updating metadata on remote iTunes...\n" if ($verbose); } if ($verbose > 1) { print STDERR "$progname: $remote\n"; my $s = $script; $s =~ s/^/\t/gm; print STDERR "$s\n"; } if (! $debug_p) { open (my $pipe, '|-', $remote) || error ("open: $remote: $!"); (print $pipe $script) || error ("pipe: $!"); close $pipe; } } # Copies iTunes metadata from the local iTunes to the remote iTunes, # for the list of (local, absolute path) files. # # Returns a hash of the local metadata interrogated. # sub sync_metadata($$@) { my ($host, $stars_only, @files) = @_; my $remote_applescript = ''; my @read_fields = @all_fields; my @write_fields = ($stars_only ? ('name', 'artist', 'enabled', 'rating', 'video kind') : @read_fields); # @write_fields = @read_fields; my %result; my $script = ("set LF to \"\\n\"\n" . "set OUTPUT to \"\"\n" . "with timeout of $forever\n" . " tell application \"Music\"\n" . " set PF to POSIX path of" . " (path to home folder from user domain) &" . " \"$music_dir\"\n\n" . " repeat with P in {"); foreach my $f (@files) { my $f2 = $f; $f2 =~ s/"/\\"/gs; $script .= "PF & "; $script .= "\"$f2\", "; } $script =~ s/, *$//s; $script .= "}\n"; $script .= (" try\n" . " set P to P as POSIX file as alias\n" . " set T to (add P as alias)\n" . " tell T\n" . " set OUTPUT to OUTPUT &" . " (POSIX path of P) & \"\t\" & " . join (" & \"\t\" & ", @read_fields) . " & LF\n" . " end tell\n" . " on error errmesg number errn\n" . " set OUTPUT to OUTPUT & \"ERROR: \" & errmesg" . " & \": \" & (errn as text) & LF\n" . " end try\n\n" . " end repeat\n" . " end tell\n" . "end timeout\n" . "OUTPUT"); #$script =~ s/([\"\$])/\\$1/gs; print STDERR "$progname: getting metadata from local iTunes...\n" if ($verbose); my $result = ''; { my @cmd = ('osascript'); my ($in, $out, $err); $err = Symbol::gensym; print STDERR "exec: " . join(' ', @cmd) . " <<\n$script\n" if ($verbose > 3); my $pid = open3 ($in, $out, $err, @cmd); $script = Encode::encode ('utf8', $script); # Unpack wide chars to UTF-8 print $in $script; close ($in); while (<$out>) { $result .= $_; } waitpid ($pid, 0); } print STDERR "$progname: result:\n\n$result\n\n" if ($verbose > 3); # Generate the script to run on the other end. # If we're adding a new track, we need to use "add" on the path. # If we're updating an old track, we need to find the "file track" # that corresponds to its file name. Guess what? There's no way # to tell whether a file path is already present in the library, or # to find the "file track" that corresponds to a file, without # iterating the entire library and comparing them one at a time, # which is really slow. # # The only other way to accomplish is to "add" on the existing file, # which returns a "file track". But this *sometimes* (but not always) # has the side effect of adding a duplicate entry, resulting in two # entries that have the same "path" and other fields but different # "database ID"s. So after doing this, we have to find and delete # those dups, too. # This code builds a function to update each track rather than just # inlining the code for two reasons: first, it makes the script smaller; # but second, it allows us to see status updates in realtime instead of # only getting them all at the end. The AppleScript "log" function # is only implemented in "AppleScript Editor.app" and in "osascript". # If you call "log" while inside of "tell application", it's a no-op. # So the only way to get real-time output to STDERR to show up is to # have a single "tell application" for each track we're updating, and # call "log" outside of that. (This may actually slow things down a # little bit, but the trade-off is worth it.) my $timeout = $forever; $timeout = 60 * 2 . " seconds"; $script = ("to W(arg_pathname"); foreach my $k (@write_fields) { my $k2 = $k; $k2 =~ s/ /_/gs; $script .= ", arg_$k2"; } $script .= (")\n" . " set LF to \"\\n\"\n" . " set PF to POSIX path of" . " (path to home folder from user domain) & \"$music_dir\"\n" . "\n" . " set OUTPUT to \"\"\n" . " with timeout of $timeout\n" . " set P to POSIX file (PF & arg_pathname)\n" . " tell application \"Music\"\n"); # Fucking pseudo-enums only parse within the application's context!! foreach my $f ("music video", "movie", "tv show", "podcast") { my $a = 'arg_video_kind'; $script .= " if $a is \"$f\" then set $a to $f\n"; } $script .= (" try\n" . " set T to (add P as alias)\n" . " tell T\n"); foreach my $k (@write_fields) { my $k2 = $k; my $v = "arg_$k"; $v =~ s/ /_/gs; if ($k =~ m/^volume adjustment/si) { # WTF? Seriously, WTF? $k2 = "{$k2}"; $v = "{$v}"; } $script .= " set $k2 to $v\n"; } $script .= (" set stars to \"\"\n" . " repeat (arg_rating / 20) times\n" . " set stars to stars & \"★\"\n" . " end repeat\n" . " if stars is \"\" then\n" . " set stars to \"☆\"\n" . " end if\n" . " set OUTPUT to OUTPUT &" . " \"Updated: \" & artist & \" - \" & name &" . " \" \" & stars\n" . " end tell\n"); # Iterate every track with the same artist/name as this # one, and see if any of them have the same pathname but # a different database ID. Nuke those. # # We have to iterate tracks, build a list of IDs, then # iterate that because Applescript is such a steaming # pile that you can't delete items that are members of a # list that you are iterating! # $script .= (" set TID to database ID of T\n" . " set TP to location of T\n" . " set TIDS to {}\n" . " repeat with T2 in every file track whose" . " artist is arg_artist" . " and name is arg_name\n" . " set TID2 to database ID of T2\n" . " if TID is not TID2" . " and TP is (location of T2) then\n" . " set TIDS to TIDS & TID2\n" . " end if\n" . " end repeat\n" . " repeat with TID2 in TIDS\n" . " set T2 to (first file track whose" . " database ID is TID2)\n"); $script .= (" set OUTPUT to OUTPUT & LF & \"Deleted dup: \"" . " & TID & \" \" & TID2\n") if ($verbose > 1); $script .= (" delete T2\n" . " end repeat\n" . " on error errmesg number errn\n" . " set OUTPUT to OUTPUT & LF & \"ERROR: \" & errmesg" . " & \": \" & (errn as text)\n" . " end try\n" . " end tell\n" . " end timeout\n"); $script .= (" log OUTPUT\n") if ($verbose); $script .= ("end W\n\n"); foreach my $line (split (/[\r\n]+/, $result)) { if ($line =~ m/^ERROR:/si) { print STDERR "$progname: $line\n"; next; } my @vv = split (/\t/, $line); my $path = shift @vv; my %ff; my @kk = @read_fields; while (@vv) { my $k = shift @kk; my $v = shift @vv; error ("unparsable line: $line\n") unless ($k && defined($v)); $v =~ s/\"/\\"/gs; $ff{$k} = $v; } $result{$path} = \%ff; $path =~ s@^/Users/[^/]+/$music_dir@@si; $script .= "W(\"" . $path . "\""; foreach my $k (@write_fields) { my $v = $ff{$k} || ''; $v = "\"$v\"" unless ($v =~ m/^(true|false|-?\d+\.?\d*)$/s); $v =~ s/^(-?\d+\.\d{3})\d+$/$1/s; # 3 decimals $script .= ", $v"; } $script .= ")\n"; # Issue a warning if the track has no volume adjustment, since that's # unlikely, and probably an indication that replaygain failed when # the video was installed in iTunes. # if (! $ff{'volume adjustment'}) { print STDERR "\n$progname: WARNING: no volume adjustment: " . $ff{'artist'} . " - " . $ff{'name'} . "\n\n"; } } # Also remove dead tracks. I'm not sure why this is necessary, but # sometimes file tracks are getting left behind for files that no # longer exist. $script .= ("log \"Cleaning up...\"\n") if ($verbose); $script .= ("\n" . "set OUTPUT to \"\"\n" . "with timeout of $forever\n" . " tell application \"Music\"\n" . " set LIB to library playlist 1\n" . " repeat with TN from (count of file tracks of LIB)" . " to 1 by -1\n" . " set T to file track TN of LIB\n" . " if location of T is missing value then\n" . " tell T\n" . " set OUTPUT to OUTPUT & \"Deleted: \"" . " & artist & \" - \" & name & \"\\n\"\n" . " end tell\n" . " delete T\n" . " end if\n" . " end repeat\n" . " end tell\n" . "end timeout\n" . "OUTPUT\n"); $script .= "log \"Remote iTunes metadata updated.\"\n" if ($verbose); run_remote_applescript ($host, $script); return \%result; } # Compare the metadata of the local playlist with the remote library. # sub diff_files($$) { my ($host, $playlist) = @_; my $timeout = $forever; $timeout = (60 * 60) . " seconds"; my @read_fields = @all_fields; my $script = ("set OUTPUT to \"\"\n" . "tell application \"Music\"\n" . " repeat with T in every file track %%PL%%\n" . " tell T\n" . " set OUTPUT to OUTPUT &" . " (POSIX path of (location of T as text))" . " & \"\\t\" & " . join (" & \"\\t\" & ", @read_fields) . " & \"\\n\"\n" . " end tell\n" . " end repeat\n" . "end tell\n" . "OUTPUT"); $script =~ s/ +/ /gm; my ($script1, $script2) = ($script, $script); $script1 =~ s/%%PL%%/of playlist "$playlist"/s; $script2 =~ s/%%PL%%/of playlist "Music"/s; $script1 =~ s/([\"\$])/\\$1/gs; print STDERR "$progname: getting metadata from local iTunes...\n" if ($verbose); my $cmd = "osascript -e \"$script1\""; my $result1 = `$cmd`; print STDERR "$progname: getting metadata from remote iTunes...\n" if ($verbose); $cmd = "ssh -xTC $host /bin/sh -c " . "'osascript << __EOF__\n$script2\n__EOF__\n'"; my $result2 = `$cmd`; my %all_files; my $R = sub($$$) { my ($type, $table, $result) = @_; foreach my $line (split (/\n/, $result)) { if ($line =~ m/^\s*$|^Killed by signal|^Exit |^\d+\.\d+u \d+\./s) { next; } elsif ($line =~ m/^ERROR:/si) { print STDERR "$progname: $type: $line\n"; next; } elsif ($line !~ m@^/.*\t@s) { print STDERR "$progname: $type: ERROR: $line\n"; next; } my @ff = @read_fields; my @vv = split (/\t/, $line); my $file = shift @vv; my %t; while (@ff) { my $k = shift @ff; my $v = shift @vv; $t{$k} = $v; } my $s = $music_dir; $s =~ s@^[^/]+/@@s; $file =~ s@^/.*/\Q$s@@si; $file = "CORRUPTED $t{artist} $t{name}" if ($file eq '/missing value'); $all_files{$file} = 1; $table->{$file} = \%t; } }; my %local; my %remote; &$R ("local", \%local, $result1); &$R ("remote", \%remote, $result2); foreach my $file (sort keys (%all_files)) { my $L = $local{$file}; my $R = $remote{$file}; if (!defined($L)) { print STDERR "$file: remote only\n"; } elsif (!defined($R)) { print STDERR "$file: local only\n"; } else { my @fail = (); foreach my $f (@read_fields) { my $LF = $L->{$f}; my $RF = $R->{$f}; my $ok = 0; if ($f eq 'volume adjustment') { # If you write "volume adjustment = 48" and then read it back, # sometimes what got written was actually "49". my $hysteresis = 2; $ok = (($LF >= $RF - $hysteresis && $LF <= $RF + $hysteresis) || ($RF >= $LF - $hysteresis && $RF <= $LF + $hysteresis)); } elsif ($LF =~ m/^-?\d+\.\d+$/s) { # Likewise, the number you write for "start" and "finish" seems # to be randomly permuted at some point after the first decimal. my $hysteresis = 0.1; $ok = (($LF >= $RF - $hysteresis && $LF <= $RF + $hysteresis) || ($RF >= $LF - $hysteresis && $RF <= $LF + $hysteresis)); if (! $ok) { $LF = sprintf("%.4f", $LF); # Truncate for the error msg $RF = sprintf("%.4f", $RF); } } else { $ok = ($LF eq $RF); } if (! $ok) { $LF = "\"$LF\"" if ($LF =~ m/^$|\s/s); $RF = "\"$RF\"" if ($RF =~ m/^$|\s/s); push @fail, "$f: $LF vs $RF"; } } print STDERR "$file: " . join ('; ', @fail) . "\n" if @fail; } } } sub error($) { my ($err) = @_; #print STDERR "$progname: $err\n"; #exit 1; die "$err\n"; } sub usage() { print STDERR "usage: $progname [--verbose] [--quiet] [--stars-only]" . " [--diff] [--debug] [--progress] [--files-only]" . " local-playlist remote-host\n"; exit 1; } sub main() { binmode (STDOUT, ':utf8'); binmode (STDERR, ':utf8'); my ($playlist, $host); my $stars_only = 0; my $files_only = 0; my $force_p = 0; my $diff_p = 0; my $progress_p = 0; my $bwlimit = undef; while ($#ARGV >= 0) { $_ = shift @ARGV; if (m/^--?verbose$/) { $verbose++; } elsif (m/^-v+$/) { $verbose += length($_)-1; } elsif (m/^--?debug$/) { $debug_p++; } elsif (m/^--?quiet$/) { $verbose--; } elsif (m/^--?stars(-only)?$/) { $stars_only++; } elsif (m/^--?files-only?$/) { $files_only++; } elsif (m/^--?force$/) { $force_p++; } elsif (m/^--?diff$/) { $diff_p++; } elsif (m/^--?progress$/) { $progress_p++; } elsif (m/^--?bwlimit$/) { $bwlimit = shift @ARGV; } elsif (m/^-./) { usage; } elsif (!$playlist) { $playlist = $_; } elsif (!$host) { $host = $_; } else { usage; } } usage unless ($playlist && $host); if ($diff_p) { diff_files ($host, $playlist); } else { sync_files ($host, $stars_only, $files_only, $force_p, $progress_p, $bwlimit, $playlist); } } eval { main(); }; if ($@) { chomp($@); print STDERR "$progname: $@\n"; exit 1; } exit 0;