#!/usr/bin/perl use warnings; use strict; use Data::Dumper; use Gearman::Client; use Getopt::Long; use Log::Handler; use MIME::Lite::TT::HTML; use Pod::Usage; use Storable qw/freeze thaw/; use YAML::Any; # Globals my $default_options = { verbose => 0, debug => 0, quiet => 0, log_file => '/tmp/handbrake-cluster-client.log', silent => 0, help => 0, pretend => 0, config_file => '', job_servers => ['build0.sihnon.net', 'build1.sihnon.net', 'build2.sihnon.net'], limit => [], email_target => '', email_sender => '', email_subject => 'Rip completed', email_timezone => 'Europe/London', email_html => 'rip-completed.html', email_text => 'rip-completed.txt', }; my $options = { map { $_ => undef } keys %$default_options }; my $default_rip_options = { nice => 15, input_dir => '', input_filename => '/dev/sr0', output_dir => '', output_filename => 'rip-output.mkv', title => 0, format => 'mkv', video_codec => 'x264', video_width => 0, video_height => 0, quantizer => 0.61,# x264 quantizer = 20 deinterlace => 0, # 0 = off, 1 = on, 2 = selective audio_tracks => 1, audio_codec => 'ac3', audio_names => 'English', subtitle_tracks => 1, }; my $rip_options = { map { $_ => undef } keys %$default_rip_options }; 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}, 'job-servers|j=s@' => \$options->{job_servers}, 'config|c=s' => \$options->{config_file}, 'limit|L=s' => sub { my ($name, $value) = @_; $options->{limit} = [split(/,/, $value)]; }, 'nice|N=i' => \$rip_options->{nice}, 'input|i=s' => \$rip_options->{input_filename}, 'output|o=s' => \$rip_options->{output_filename}, 'title|t=s' => \$rip_options->{title}, 'format|f=s' => \$rip_options->{format}, 'video-encoder|e=s' => \$rip_options->{video_codec}, 'width|w=i' => \$rip_options->{video_width}, 'height|l=i' => \$rip_options->{video_height}, 'quantizer|Q=f' => \$rip_options->{quantizer}, 'deinterlate|D=i' => \$rip_options->{deinterlace}, 'audio-tracks|a=s' => \$rip_options->{audio_tracks}, 'audio-encoder|E=s' => \$rip_options->{audio_codec}, 'audio-name|A=s' => \$rip_options->{audio_names}, 'subtitle-tracks|S=s' => \$rip_options->{subtitle_tracks}, 'email-target=s' => \$options->{email_target}, 'email-sender=s' => \$options->{email_sender}, 'email-subject=s' => \$options->{email_subject}, ) or pod2usage(-verbose => 0); pod2usage(-verbose => 1) if ($options->{help}); # Parse the configuration file (if any), and merge/validate the options my $config = parse_config($options->{config_file}, $default_rip_options); ($config, $options) = process_config($config, $options, $default_options); # Create a list of jobs from the configuration file, and command line options my %jobs = %{ $config->{jobs} } if defined $config->{jobs}; $jobs{__commandline} = $rip_options if $options->{title}; die "No rips specified" unless %jobs; # Setup logging my $log = Log::Handler->new(); # default to logging warning+, unless quiet in which case log error+, or debug in which case log everything my $maxLogLevel = ($options->{debug} ? 'debug' : ($options->{quiet} ? 3 : $options->{verbose} + 4)); # Ignore the quiet option when logging to disk 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, }, ); } # Setup the distribution client my $client = Gearman::Client->new; $client->job_servers($options->{job_servers}); # Add new ripping task for each job to run my %progress; my $taskset = $client->new_task_set; foreach my $job_id (keys %jobs) { my $job = $jobs{$job_id}; # Check that the job hasn't been restricted by the limit option next if (!$options->{limit} || !$options->{job_permitted}->{$job_id}); $progress{$job_id} = 0; $taskset->add_task('handbrake_rip', freeze($job), { on_status => sub { my $numerator = shift; my $denominator = shift or die; $progress{$job_id} = $numerator / $denominator; display_progress(%progress); }, on_complete => \&on_complete_handler, on_retry => sub { my $attempt = shift or die; $log->warning("Retrying '$job_id' rip"); }, on_fail => sub { $log->warning("Rip '$job_id' failed"); }, } ); $log->info("Enqueued '$job_id' rip"); } $taskset->wait; sub on_complete_handler { my $result_ref = shift or die; my $response = thaw($$result_ref); if ($options->{email_target}) { my $email = MIME::Lite::TT::HTML->new( From => $options->{email_sender}, To => $options->{email_target}, Subject => $options->{email_subject}, TimeZone => $options->{email_timezone}, Encoding => 'quoted-printable', Template => { html => $options->{email_html}, text => $options->{email_text}, }, Charset => 'utf8', TmplOptions => { INCLUDE_PATH => '.', }, TmplParams => { success => $response->{success}, filename => $response->{output_filename}, real_filename => $response->{real_output_filename}, log => join "\n", @{ $response->{log} }, }, ); $email->send; } $log->notice("Completed rip to $response->{real_output_filename}"); } sub display_progress { my %progress = @_; local $|; $| = 1; print "Completion: " . join(' ', map { "$_($progress{$_}%)" } keys %progress) . "\r"; } # Reads configuration options from a config file, expands the internal references, and returns the expanded form. sub parse_config { my $config_file = shift; my $default_rip_options = shift or die; return {} unless defined $config_file; my $config = YAML::Any::LoadFile($options->{config_file}) or die 'Unable to load configuration file: ' . $!; # Iterate through each job, and inject any preset variables that haven't been redefined by the job if (defined $config->{jobs}) { foreach my $job_id (keys %{ $config->{jobs} }) { my $job = $config->{jobs}->{$job_id}; $job = $config->{jobs}->{$job_id} = {} unless $job; if ($job->{presets}) { foreach my $preset_name (@{ $job->{presets} }) { foreach my $preset_key (keys %{$config->{presets}->{$preset_name}}) { $job->{$preset_key} = $config->{presets}->{$preset_name}->{$preset_key} unless $job->{$preset_key}; } } } # Inject any default variables that haven't been defined by the job or preset foreach my $rip_option (keys %$default_rip_options) { $job->{$rip_option} = $default_rip_options->{$rip_option} unless $job->{$rip_option}; } } } return $config; } sub process_config { my $config = shift or die; my $options = shift or die; my $default_options = shift or die; # Update any unset options with values from config file foreach my $option (keys %$options) { # Update the value of any option which has not been set by a command line argument if (!$options->{$option}) { # Try the config file first, otherwise use the default value if (defined $config->{options}->{$option}) { $options->{$option} = $config->{options}->{$option}; } else { $options->{$option} = $default_options->{$option}; } } } # Flag jobs that may be run if any limits are specified my %job_permitted = map { $_ => 0 } keys %{ $config->{jobs} }; foreach my $job_id (@{ $options->{limit} }) { $job_permitted{$job_id} = 1; } $options->{job_permitted} = \%job_permitted; # Validate the email options if ($options->{email_target}) { $options->{email_sender} = $options->{email_target} unless $options->{email_sender}; } return ($config, $options); } __END__; =head1 NAME handbrake-cluster-client - Sets up DVD ripping tasks to be distributed =head1 SYNOPSIS handbrake-cluster-client.pl -h|--help handbrake-cluster-client.pl [[-v|--verbose] | [-d|--debug] | [-q|--quiet]] | [-s|--silent]] [-l|--log ] [-n|--pretend] [-c|--config ] [-N|--nice ] [-i|--input ] [-o|--output ] [-f|--format ] [-t|--title ] [-e|--video-encoder <ffmpeg|theora|x264>] [-w|--width <width>] [-l|--height <height>] [-Q|--quantizer <quality>] [-D|--deinterlace <0|1|2>] [-a|--audio-tracks <track list>] [-E|--audio-encoder <faac/lame/vorbis/ac3/dts>[,...]] [-A|--audio-name <audio name list>] [-s|--subtitle-tracks <subtitle track list>] [--email-target <email address>] [--email-sender <email address>] [--email-subject <subject line>] =head1 DESCRIPTION Sets up one or more DVD ripping tasks, and hands them off to gearman for distribution. Logging and ripping configuration can be controlled with command line arguments. =head1 OPTIONS =head2 Administrative Options =over 4 =item B<-h|--help> Displays this help information and quits. =item B<-v|--verbose> Displays verbose logging information. =item B<-d|--debug> Displays full debugging information, including all output from HandBrake. =item B<-q|--quiet> Displays only errors. =item B<-s|--silent> Displays no output whatsoever, useful for scripting. Output will still be logged to disk if a file is specified. =item B<-l|--log E<lt>log fileE<gt>> Specifies the name of a file to write logging information to. =item B<-n|--pretend> Only shows what action would be taken, no rips are actually performed. =item B<-j|--job-servers E<lt>server listE<gt>> Specifies a comma separated list of alternate servers to use for job distribution. =item B<-c|--config E<lt>config fileE<gt>> Specifies the name of a configuration file which contains additional options, and a list of ripping jobs. =item B<-n|--nice E<lt>nice levelE<gt>> Specifies the priority to give the ripping process, using nice. Defaults to 15. =back =head2 Ripping Options =over 4 =item B<-i|--input E<lt>input filenameE<gt>> Specifies the DVD drive device, or path to a VIDEO_TS folder to use as input for the rip. =item B<-o|--output E<lt>output filenameE<gt>> Specifies the name of the file the rip will be saved to. This is only used as a template; the actual rip will be saved to a filename with a unique component at the end to avoid accidental overwrites. =item B<-f|--format E<lt>mkv|mp4E<gt>> Selects the container used for the rip. Defaults to mkv. =item B<-t|--title E<lt>titleE<gt>> Specifies the DVD title to rip. By default, the longest title will be used. =item B<-e|--video-encoder E<lt>ffmpeg|theora|x264E<gt>> Specifies the video encoder to use for the rip. Defaults to x264. =item B<-w|--width E<lt>widthE<gt>> Specifies the width of the output rip in pixels. =item B<-l|--height E<lt>heightE<gt>> Specifies the height of the output rip in pixels. =item B<-Q|--quantizer E<lt>qualityE<gt>> Specifies the x264 quantizer property, in the form of a real between 0 and 1. Defaults to 0.61, aka "20". =item B<D|--deinterlace E<lt>0|1|2E<gt>> Specifies the type of deinterlacing to use. Use 0 to disable completely. Use 1 for full deinterlacing, or 2 for selective deinterlacing (recommended). Defaults to 0. =item B<-a|--audio-tracks E<lt>audio track listE<gt>> Specifies a comma separated list of audio tracks to include in the rip. Defaults to 1. =item B<-E|--audio-encoder E<lt>faac/lame/vorbis/ac3/dts>E<gt>> Specifies the audio encoder to use for the rip. Defaults to ac3 pass-through. =item B<-A|--audio-name E<lt>audio track namesE<gt>> Specifies a comma separated list of names for the selected audio tracks. Defaults to 'English'. =back =head2 Reporting Options =over 4 =item B<--email-target E<lt>email addressE<gt>> Specifies an email address to send a completion report to after each rip finishes. =item B<--email-sender E<lt>email addressE<gt>> Specifies an email address to use as the sender of the rip completion email reports. Defaults to the same as the target address. =item B<--email-subject E<lt>subject lineE<gt>> Overrides the subject line to use in the rip completion email reports. =back =head1 CONFIGURATION This script accepts a configuration file in YAML format to specify multiple ripping tasks, or provide values for commonly used options. Command line arguments take precedence to options specified in config files. This file is split into three sections: =over 4 =item B<options> Values for command-line options from the I<Administrative Options> section above can be specified here, using variables with the same name as the long options (with hyphens replaced with underscores). options: job_servers: build0.example.com log_file: /tmp/handbrake.log =item B<presets> Presets allow the grouping of common job configuration options, for example the input filename when ripping multiple episodes from a single DVD image. Any option from the I<Ripping Options> section can be specified in a preset, and options specified in a job take precedence in case of a clash. preset: disk1: input_filename: /tmp/disk1/VIDEO_TS tvseries: format: mkv video_codec: x264 =item B<jobs> Specifies a list of ripping tasks to perform. Variables from the I<Ripping Options> section above can be used here. Additionally, you can include all of the options from one or more presets. jobs: - presets: - disk1 - tvseries output_filename: episode1.mkv =back =head2 EXAMPLE CONFIGURATION An example configuration file is included in the distribution as C<config.yml>. Refer to this when writing your own files. =cut