#!/usr/bin/perl use strict; use warnings; use Data::Dumper; use Getopt::Long; use File::Basename; use Log::Handler; use Pod::Usage; use Switch; use Sys::Pushd; use File::Copy; use FindBin; use lib $FindBin::Bin; # Globals our %options = ( verbose => 0, quiet => 0, log_file => '/tmp/tvmover.log', silent => 0, help => 0, pretend => 0, interactive => 0, media_dir => '/export/videos/', torrent_dir => '/export/torrents/completed/', tvrenamer => '/usr/bin/tvrenamer', ); Getopt::Long::Configure( qw(bundling no_getopt_compat) ); GetOptions( 'verbose|v+' => \$options{verbose}, 'debug|d' => \$options{debug}, 'quiet|q' => \$options{quiet}, 'silent|s' => \$options{silent}, 'log|l=s' => \$options{log_file}, 'help|h' => \$options{help}, 'pretend|n' => \$options{pretend}, 'interactive|i' => \$options{interactive}, 'media-dir|m=s' => \$options{media_dir}, 'torrent-dir|t=s' => \$options{torrent_dir}, 'tvrenamer=s' => \$options{tvrenamer}, ) or pod2usage(-verbose => 0); pod2usage(-verbose => 1) if ($options{help}); # Override the series name for calls to tvrenamer for select shows my %series_overrides = ( csinewyork => 'CSINY', ); #setup logging my $log = Log::Handler->new(); my $maxLogLevel = ($options{debug} ? 'debug' : ($options{quiet} ? 3 : $options{verbose} + 4)); # default to logging warning+, unless quiet in which case log error+, or debug in which case log everything my $maxFileLogLevel = ($options{debug} ? 'debug' : 'info'); if ( ! $options{silent}) { $log->add( screen => { log_to => 'STDOUT', minlevel => 'emergency', maxlevel => $maxLogLevel, }, ); } if ( $options{log_file}) { $log->add( file => { filename => $options{log_file}, minlevel => 'emergency', maxlevel => $maxFileLogLevel, }, ); } # Get a list of tv series directories, and their normalised names my %series_dir = read_series_directories($options{media_dir}); # Scan the torrent directory for completed files process_completed_directory($options{torrent_dir}); # done! sub process_completed_directory { my $base_directory = shift or die; my $dh; $log->notice("Reading contents of $base_directory"); opendir $dh, $base_directory; while (my $completed_torrent = readdir $dh) { next if $completed_torrent =~ m/^\..*/; my $completed_filename = $base_directory . '/' . $completed_torrent; next if -l $completed_filename; # ignore symlinks # If the entry is a directory and is named after an episode, attempt to scan it if ( -d $completed_filename) { # Skip any "sample" directories next if $completed_torrent =~ m/sample/i; # Attempt to grab the series name from the dir name my ($series) = decode_filename($completed_torrent); next unless $series; $log->info("Moving into subdirectory $completed_filename"); process_completed_directory($completed_filename); } else { $log->notice("Dealing with $completed_torrent ($completed_filename)"); # Only move video files next unless ($completed_torrent =~ m/\.(avi|mkv)$/i); my $output_dir = get_destination_dir_from_filename($completed_torrent); if ( ! $output_dir) { $log->warning("Failed to find directory for $completed_torrent"); next; } my $output_filename = $output_dir . '/' . $completed_torrent; # Copy the file over to the media share copy_to_media_share($completed_filename, $output_filename) or next; # Attempt to rename the file my $final_filename = $output_filename; $final_filename = rename_media_share_file($output_filename); # Symlink the file back into the torrent directory for seeding replace_with_symlink($final_filename, $completed_filename); } } } sub read_series_directories { my $media_share_dir = shift or die; my %series_dir = (); my $dh; opendir $dh, $media_share_dir; while (my $file = readdir $dh) { next if $file =~ m/^\..*/; my $canonical_file = canonicalise($file); $series_dir{$canonical_file} = $file; } return %series_dir; } sub canonicalise { my $input = shift or die; $input =~ tr/A-Z/a-z/; $input =~ s/[^a-z0-9]//g; # Strip out trailing year numbers $input =~ s/(19|20)\d\d$//; # Strip out trailing country codes $input =~ s/(us|uk)$//; # Handle a couple of series specific haxes $input =~ s/csiny/csinewyork/; $input =~ s/ncisla/ncislosangeles/; $input =~ s/theclevelandshow/clevelandshow/; return $input; } sub copy_to_media_share { my $torrent_dir_filename = shift or die; my $output_filename = shift or die; $log->info("Copying $torrent_dir_filename to $output_filename"); if ($options{interactive}) { return unless confirm("Copy $torrent_dir_filename to $output_filename? [y/N]"); } if ( ! $options{pretend}) { return copy $torrent_dir_filename, $output_filename; } else { return 1; # Return true if only pretending } } sub get_destination_dir_from_filename { my $source_filename = shift or die; my ($series, $season, $episode) = decode_filename($source_filename); return unless $series; if ($season) { return $options{media_dir} . '/' . $series . '/Season ' . $season; } else { return $options{media_dir} . '/' . $series; } } sub decode_filename { my $input_filename = shift or die; # grab the series, and season number from the torrented filename $input_filename =~ m/(.*?)[-_\.]*(?:(\d+)x(\d+)|[sS](\d+)[eE](\d+)|P(?:ar)?t(\d+))/; my $series = $1; my $season = $2 || $4; my $episode = $3 || $5 || $6; return unless $series; $log->debug("Series: $series, Season: $season, Episode: $episode"); # Get the real series name if it exists my $canonical_series = canonicalise($series) or return; # Sanitise the season and episode numbers $season =~ s/0+(\d+)/$1/ if $season; return ($series_dir{$canonical_series}, $season, $episode); } sub replace_with_symlink { my $media_share_filename = shift or die; my $torrent_dir_filename = shift or die; my $temp_filename = $torrent_dir_filename . '.tmp'; $log->notice("Replacing $torrent_dir_filename with symlink to $media_share_filename"); if ($options{interactive}) { return unless confirm("Replace $torrent_dir_filename with symlink to $media_share_filename? [y/N]"); } if ( ! $options{pretend}) { symlink $media_share_filename, $temp_filename or return; move $temp_filename, $torrent_dir_filename; } } sub rename_media_share_file { my $input_filename = shift or die; my $output_filename = $input_filename; $log->notice("Attempting to rename $input_filename using tvrenamer"); return $output_filename if $options{pretend}; my $input_dir = dirname($input_filename); # Find out the season and episode number for the file we are renaming my ($series, $season, $episode) = decode_filename(basename($input_filename)); return $output_filename unless $series; my $canonical_series = canonicalise($series); # Change into the output directory temporarily, to run the renamer my $changed_dir = new Sys::Pushd $input_dir; # Certain shows require a custom call to tvrenamer in order to work, eg CSI:NY my $override_series = ''; if ($series_overrides{$canonical_series}) { $log->debug("Overriding series name for $series with $series_overrides{$canonical_series}"); $override_series = "--series='$series_overrides{$canonical_series}'"; } my $tvrenamer_cmd = "$options{tvrenamer} --unattended --noANSI $override_series --rangemin=$episode --rangemax=$episode --postproc='s/-img---a- -a-//;'"; open my $tvrenamer_fh, "$tvrenamer_cmd|"; my @tvrenamer_output = <$tvrenamer_fh>; close $tvrenamer_fh; # Scan through the output and look for the new filename for the episode with this season and episode number foreach my $line (@tvrenamer_output) { my $identifier = $season . 'x' . $episode; if ($line =~ m/$identifier/) { $output_filename = $line; chomp $output_filename; $log->notice("File has been renamed from $input_filename to $output_filename"); return $input_dir . '/' . $output_filename; } } return $output_filename; } sub confirm { my $message = shift or die; print $message, ": "; my $response = ; return $response =~ m/^[yY]$/; } __END__; =head1 NAME =head1 SYNOPSIS =head1 DESCRIPTION =head1 OPTIONS =cut