#!/usr/bin/perl

# This script uses a MythTV database to transcode recorded TV programs to
# MPEG-4 video using the H.264 and AAC codecs, cutting the video at certain
# commercial points selected by the user within MythTV's frontend or by an
# automatic commercial flagger, complete with SRT subtitles extracted from
# MythTV's embedded VBI closed-caption data and iTunes-style metadata about
# the program, if it can be found.

# In short, this script calls a bunch of programs to convert a file like
# 1041_20100523000000.mpg into a file like
# /usr/video/Program Name/Program Name - Episode Name.mp4, including captions,
# file tags, and with commercials clipped.

# Requirements:
# - FFmpeg
# - x264 (ffmpeg will be called with -vcodec libx264)
# - neroAacEnc (http://www.nero.com/enu/technologies-aac-codec.html)
# - MP4Box (http://gpac.sourceforge.net)
# - ccextractor (http://ccextractor.sourceforge.net)
# - AtomicParsley (http://atomicparsley.sourceforge.net - the tarball labelled
#                  'debian' contains a generic Linux binary)

# Usage: <script> %CHANID% %STARTTIME%

# Script adapted from a script by Christopher Neufeld, released GPLv2.
# May not work correctly on recordings that span the autumn time change.

use strict;
use warnings;
use POSIX "mkfifo";
use Cwd;
use DBI;
use File::Copy;

# Make sure the executables mentioned above can be found in
# one of these directories.
$ENV{'PATH'} = '/bin:/usr/bin:/usr/local/bin';

# Enter MythTV database information below. Below are the default values.
my $mysql_host = "localhost";
my $mysql_db = "mythconverg";
my $mysql_user = "mythtv";
my $mysql_passwd = "mythtv";

# This directory will be used to store intermediate files while encoding, and
# should be large enough to hold at least the H.264-encoded version of a
# reasonably sized recording without any commercial cuts.
# Around 4-8GB free should work well, but 2GB might be okay.
my $tmp_dir = "/tmp";

# This directory will store the generated MP4 files. Subdirectories will be
# created for each series.
my $final_dir = "/usr/video";

# When cutting commercials at the beginning or end of a video, sometimes
# MythTV's automatic commercial flagger will mark the cut-point a few seconds
# ahead or behind the actual start or end point of the video. Therefore, any
# clips at the beginning or end of the video that are shorter than this
# threshold (in seconds) will be ignored.
my $thresh = 5;

# Average bitrate of the H.264 video, in kb/s.
my $vid_br = 1000;

# Average quality of the AAC audio, from 0 to 1.
my $audio_q = 0.3;

# Whether to make the resulting files iPod Touch 3G-compatible.
my $iPod = 1;

# ISO 639-2 three-letter language code to use for audio and subtitle tracks.
my $lang_code = "eng";

sub short_time_to_long {
  my $st = shift;
  my $t = "";
  $t .= substr($st, 0, 4) . "-" . substr($st, 4, 2) . "-" . substr($st, 6, 2);
  $t .= " ";
  $t .= substr($st, 8, 2) . ":" . substr($st, 10, 2) . ":" . substr($st, 12, 2);
  return $t; }

sub long_time_to_short {
  my $lt = shift;
  $lt =~ tr/ :-//d;
  return $lt; }

sub seconds_to_time {
  my $seconds = shift;
  my $hours = int($seconds / 3600);
  $seconds -= $hours * 3600;
  my $minutes = int($seconds / 60);
  $seconds -= $minutes * 60;
  return sprintf "%.2d:%.2d:%.2d", $hours, $minutes, $seconds; }

sub seconds_to_time_frac {
  my $seconds = shift;
  my $hours = int($seconds / 3600);
  $seconds -= $hours * 3600;
  my $minutes = int($seconds / 60);
  $seconds -= $minutes * 60;
  my $split = $seconds - int($seconds);
  my $s = "";
  if($split != 0) {
    $s = sprintf "%.4f", $split;
    $s =~ s/^[^.]*\./\./; }
  return sprintf "%.2d:%.2d:%.2d$s", $hours, $minutes, $seconds; }

sub add_trail_slash {
  my $str = shift;
  if($$str !~ m/\/$/) {
    $$str .= "/"; } }

sub get_path {
  my ($channel, $long_time) = @_;
  my $path;
  my $db = DBI->connect("DBI:mysql:database=$mysql_db;host=$mysql_host",
                        $mysql_user, $mysql_passwd);
  die "Could not connect to database." unless defined $db;
  my $query = $db->prepare("select endtime, basename, storagegroup from " .
                           "recorded where chanid = ? and starttime = ?;");
  $query->execute($channel, $long_time);
  my ($endtime, $basename, $storagegroup) = $query->fetchrow_array();
  die "Unable to locate recording basename for channel $channel " .
      "and time $long_time." unless defined $basename;
  $query = $db->prepare("select dirname from storagegroup " .
                        "where groupname = ?;");
  $query->execute($storagegroup);
  while(my @dir = $query->fetchrow_array()) {
    if(-e $dir[0] . $basename) {
      $path = $dir[0] . $basename; } }
  die "Unable to find recording." unless defined $path;
  return $path; }

sub get_video_param {
  my $video = shift;
  my ($fps, $duration, $res);
  open my $pipe, "ffmpeg -i $video 2>&1 |"
      or die "Unable to determine FPS / duration.";
  foreach my $line (<$pipe>) {
    if($line =~ m/([0-9]*\.?[0-9]*) fps/) {
      $fps = $1; }
    elsif($line =~ m/Duration: ([0-9]+):([0-9]+):([0-9]+)\.([0-9]+),/) {
      $duration = $4 / 100 + $3 + $2 * 60 + $1 * 3600; }
    if($line =~ m/Video:.* ([0-9]+)x([0-9]+) \[/) {
      $res = [$1, $2]; } }
  close $pipe;
  die "Unable to determine FPS of video." unless defined $fps;
  die "Unable to determine duration of video." unless defined $duration;
  die "Unable to determine resolution of video." unless defined $res;
  return ($fps, $duration, $res); }

sub get_cutlist {
  my ($channel, $time) = @_;
  my @cutlist;
  print "*** Obtaining cut list ***\n";
  open my $pipe, "mythcommflag --getcutlist -c $channel -s $time 2>/dev/null |"
      or die "Unable to obtain cut list.";
  foreach my $line (<$pipe>) {
    if($line =~ m/^Cutlist: /) {
      my @afterCut = split / /, $line;
      if(@afterCut > 1 and $afterCut[1] ne "\n") {
        @cutlist = split /,/, $afterCut[1]; } } }
  close $pipe;
  return @cutlist; }

sub get_cut {
  my $cut = shift;
  my @spl = split /-/, $cut;
  return ($spl[0], $spl[1]); }

sub extract_captions {
  my ($start, $end, $delay, $subs, $fps, $files) = @_;
  my $start_s = seconds_to_time($start / $fps);
  my $end_s = seconds_to_time($end / $fps);
  my $delay_s = int($delay / $fps * 1000);
  my $blank = 1;
  print "*** Extracting captions to $files->{'srt'} ***\n";
  system "ccextractor -o $files->{'fifo'} -startat $start_s -endat $end_s " .
         "-delay -$delay_s -utf8 --no_progress_bar $files->{'video'} " .
         "> /dev/null 2>&1 &";
  open my $pipe, '<', $files->{'fifo'};
  open my $file, '>>', $files->{'srt'};
  foreach my $line (<$pipe>) {
    if($blank == 1) {
      print $file ++$$subs . "\n";
      $blank = 0; }
    else {
      if($line =~ m/^\s*$/) {
        $blank = 1; }
      print $file $line; } }
  close $pipe;
  close $file; }

sub adjust_res {
  my $res = shift;
  my $fmt = "mp4";
  my $prof = "high";
  my $size = "";
  if($iPod) {
    my $aspect = $res->[0] * 1.0 / $res->[1];
    if($aspect > (640.0 / 480.0)) {
      my $hres = int(640.0 / $aspect + 0.5);
      if($hres % 2 == 1) {
        $hres += 1; }
      my $pad = (480 - $hres) / 2;
      $size = "-s 640x" . $hres . " -padtop $pad -padbottom $pad"; }
    else {
      my $vres = int(480.0 * $aspect + 0.5);
      if($vres % 2 == 1) {
        $vres += 1; }
      my $pad = (640 - $vres) / 2;
      $size = "-s " . $vres . "x480 -padleft $pad -padright $pad"; }
    $fmt = "ipod";
    $prof = "ipod640"; }
  return ($fmt, $prof, $size); }

sub encode_video {
  my ($start, $end, $fps, $res, $suffix, $files) = @_;
  my $start_s = $start / $fps;
  my $dur_s = $end / $fps - $start_s;
  my $file = $files->{'h264'} . $suffix;
  my $cwd = getcwd();
  unlink $file if -e $file;
  chdir $files->{'dir'};
  my ($fmt, $prof, $size) = adjust_res($res);
  print "*** Encoding video to $files->{'dir'} - first pass ***\n";
  system "ffmpeg -y -i $files->{'video'} -ss $start_s -t $dur_s -pass 1 " .
         "-an -vcodec libx264 -vb " . $vid_br . "k -bt " . $vid_br . "k " .
         "-vpre slow_firstpass -vpre $prof $size -threads 0 -f h264 " .
         "/dev/null > /dev/null 2>&1";
  print "*** Encoding video to $file - second pass ***\n";
  system "ffmpeg -y -i $files->{'video'} -ss $start_s -t $dur_s -pass 2 " .
         "-an -vcodec libx264 -vb " . $vid_br . "k -bt " . $vid_br . "k " .
         "-vpre slow -vpre $prof $size -threads 0 -f $fmt $file " .
         "> /dev/null 2>&1";
  die "Could not encode video." unless -e $file and ! -z $file;
  chdir $cwd; }

sub encode_audio {
  my ($start, $end, $fps, $suffix, $files) = @_;
  my $start_s = $start / $fps;
  my $dur_s = $end / $fps - $start_s;
  my $file = $files->{'aac'} . $suffix;
  unlink $file if -e $file;
  print "*** Encoding audio to $file ***\n";
  system "ffmpeg -y -i $files->{'video'} -ss $start_s -t $dur_s -vn " .
         "-acodec pcm_s16le -f wav - 2>/dev/null | " .
         "neroAacEnc -ignorelength -q $audio_q -if - -of $file " .
         "> /dev/null 2>&1";
  die "Could not encode audio." unless -e $file and ! -z $file; }

sub segment {
  my ($start, $end, $delay, $seg, $subs, $fps, $res, $files) = @_;
  my $suffix = "-" . $$seg++;
  my $start_s = seconds_to_time($start / $fps);
  my $end_s = seconds_to_time($end / $fps);
  print "*** Segment $$seg: [$start_s - $end_s] ***\n";
  extract_captions($start, $end, $delay, $subs, $fps, $files);
  encode_audio($start, $end, $fps, $suffix, $files);
  encode_video($start, $end, $fps, $res, $suffix, $files); }

sub clean_tmp {
  my ($files, $fifo) = @_;
  my @log2pass = ("ffmpeg2pass-0.log", "x264_2pass.log",
                  "x264_2pass.log.mbtree");
  print "*** Removing temporary files ***\n";
  unlink $files->{'srt'} if -e $files->{'srt'};
  if($fifo) {
    mkfifo $files->{'fifo'}, 0700 unless -p $files->{'fifo'}; }
  else {
    unlink $files->{'fifo'} if -e $files->{'fifo'}; }
  for my $file (@log2pass) {
    $file = $files->{'dir'} . $file;
    unlink $file if -e $file; } }

sub cut_and_transcode {
  my ($duration, $fps, $r, $cutlist, $files) = @_;
  my $pos = 0;
  my $delay = 0;
  my $seg = 0;
  my $subs = 0;
  clean_tmp($files, 1);
  for my $cut (@$cutlist) {
    my ($start, $end) = get_cut($cut);
    if($start > $thresh * $fps && $start > $pos) {
      segment($pos, $start, $delay, \$seg, \$subs, $fps, $r, $files); }
    $delay += $end - $start;
    $pos = $end; }
  if($pos < ($duration - $thresh) * $fps) {
    segment($pos, $duration * $fps, $delay, \$seg, \$subs, $fps, $r, $files); }
  unlink $files->{'fifo'} if -p $files->{'fifo'};
  return $seg; }

sub remux {
  my ($seg, $files) = @_;
  print "*** Remuxing to $files->{'mp4'} ***\n";
  unlink $files->{'mp4'} if -e $files->{'mp4'};
  unlink $files->{'mp4tmp'} if -e $files->{'mp4tmp'};
  my $tmp = $files->{'mp4'};
  $tmp =~ s/\/[^\/]*$//;
  for(my $s = 0; $s < $seg; $s++) {
    my $h264 = $files->{'h264'} . "-" . $s;
    my $aac = $files->{'aac'} . "-" . $s;
    if($s == 0) {
      system "MP4Box -tmp $tmp -add $h264 -add $aac $files->{'mp4'}" .
             " > /dev/null 2>&1";
      die "Could not remux video." unless -e $files->{'mp4'}
          and ! -z $files->{'mp4'}; }
    else {
      system "MP4Box -tmp $tmp -add $h264 -add $aac $files->{'mp4tmp'}" .
             " > /dev/null 2>&1";
      die "Could not remux video." unless -e $files->{'mp4tmp'}
          and ! -z $files->{'mp4tmp'};
      system "MP4Box -tmp $tmp -cat $files->{'mp4tmp'} $files->{'mp4'}" .
             " > /dev/null 2>&1";
      unlink $files->{'mp4tmp'}; }
    unlink $h264;
    unlink $aac; }
  system "MP4Box -tmp $tmp -add $files->{'srt'} $files->{'mp4'}" .
         " > /dev/null 2>&1";
  system "MP4Box -tmp $tmp -lang $lang_code $files->{'mp4'}" .
         " > /dev/null 2>&1";
  unlink $files->{'srt'}; }

sub get_metadata {
  my ($channel, $long_time) = @_;
  my ($path, @credits);
  my $count = 0;
  my $db = DBI->connect("DBI:mysql:database=$mysql_db;host=$mysql_host",
                        $mysql_user, $mysql_passwd);
  die "Could not connect to database." unless defined $db;
  my $query = $db->prepare("select title, subtitle, description, category," .
                           "originalairdate, syndicatedepisodenumber from " .
                           "recordedprogram where chanid = ? and " .
                           "starttime = ?;");
  $query->execute($channel, $long_time);
  my $hash = $query->fetchrow_hashref();
  die "Unable to obtain metadata for channel $channel and time $long_time."
      unless defined $hash;
  $query = $db->prepare("select rating from recordedrating " .
                        "where chanid = ? and starttime = ?;");
  $query->execute($channel, $long_time);
  $hash->{'rating'} = $query->fetchrow_array();
  $query = $db->prepare("select name from channel where chanid = ?;");
  $query->execute($channel);
  $hash->{'channel'} = $query->fetchrow_array();
  $query = $db->prepare("select name, role from recordedcredits, people " .
                        "where chanid = ? and starttime = ? and " .
                        "people.person = recordedcredits.person order by " .
                        "role, substring_index(name, ' ', -1) asc;");
  $query->execute($channel, $long_time);
  while(my @person = $query->fetchrow_array()) {
    my $role = lc $person[1];
    $role =~ s/_/ /g;
    push @credits, [$person[0], $role]; }
  $hash->{'credits'} = \@credits;
  return $hash; }

sub print_metadata {
  my $meta = shift;
  print "*** Printing file metadata ***\n";
  for my $key (keys %$meta) {
    if($key ne "credits") {
      my $val = $meta->{$key};
      print "$key: $val\n"; } }
  my $credits = $meta->{'credits'};
  if(@$credits == 0) {
    print "No credits\n"; }
  else {
    print "Credits:\n";
    for my $credit (@$credits) {
      print "$credit->[0] ($credit->[1])\n"; } } }

sub get_versions {
  my ($ffmpeg, $x264, $nero, $pipe);
  open $pipe, "ffmpeg -version 2> /dev/null |";
  foreach my $line (<$pipe>) {
    if($line =~ m/^(FFmpeg.*)$/) {
      $ffmpeg = $1; } }
  close $pipe;
  open $pipe, "x264 --version 2> /dev/null |";
  foreach my $line (<$pipe>) {
    if($line =~ m/^(x264.*)$/) {
      $x264 = $1; } }
  close $pipe;
  open $pipe, "neroAacEnc 2>&1 |";
  foreach my $line (<$pipe>) {
    if($line =~ m/Package version:\s*([0-9.]+)/) {
      $nero = "Nero AAC Encoder $1"; } }
  close $pipe;
  return "$ffmpeg, $x264, $nero"; }

sub xml_credits_section {
  my ($people, $key, $xml) = @_;
  if(@$people > 0) {
    $$xml .= "    <key>$key</key>\n";
    $$xml .= '    <array>' . "\n";
    for my $member (@$people) {
      $$xml .= '      <dict>' . "\n";
      $$xml .= '        <key>name</key>' . "\n";
      $$xml .= "        <string>$member</string>\n";
      $$xml .= '      </dict>' . "\n"; }
    $$xml .= '    </array>' . "\n"; } }

sub xml_credits {
  my $meta = shift;
  my $credits = $meta->{'credits'};
  my $xml;
  if(@$credits == 0) {
    return $xml; }
  my (@cast, @directors, @producers);
  for my $credit (@$credits) {
    if($credit->[1] eq 'actor' or $credit->[1] eq 'host') {
      push @cast, $credit->[0]; }
    elsif($credit->[1] eq 'director') {
      push @directors, $credit->[0]; }
    elsif($credit->[1] eq 'executive producer') {
      push @producers, $credit->[0]; } }
  for my $credit (@$credits) {
    if($credit->[1] eq 'guest star') {
      push @cast, $credit->[0]; }
    elsif($credit->[1] eq 'producer') {
      push @producers, $credit->[0]; } }
  for my $credit (@$credits) {
    if($credit->[1] eq 'writer') {
      push @producers, $credit->[0]; } }
  if(@cast == 0 and @directors == 0 and @producers == 0) {
    return $xml; }
  $xml .= '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
  $xml .= '<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN"' .
          ' "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' . "\n";
  $xml .= '<plist version="1.0">'. "\n";
  $xml .= '  <dict>' . "\n";
  xml_credits_section(\@cast, 'cast', \$xml);
  xml_credits_section(\@directors, 'directors', \$xml);
  xml_credits_section(\@producers, 'producers', \$xml);
  $xml .= '  </dict>' . "\n";
  $xml .= '</plist>' . "\n\n";
  $xml =~ s/\"/\\\"/g;
  return $xml; }

sub write_metadata {
  my ($long_time, $meta, $files) = @_;
  my $str = "AtomicParsley $files->{'mp4'}";
  my $versions = get_versions();
  my $file = $files->{'mp4'};
  $file =~ s/\.mp4$//;
  my $final;
  if(defined $meta->{'title'} and defined $meta->{'subtitle'}) {
    my $title_dir = $files->{'finaldir'} . $meta->{'title'};
    mkdir $title_dir unless -d $title_dir;
    $final = $title_dir . "/" . $meta->{'title'} . " - " .
             $meta->{'subtitle'} . ".mp4"; }
  else {
    $final = $files->{'mp4'}; }
  my @tmp = glob "$file-temp-*.mp4";
  for my $f (@tmp) {
    unlink $f; }
  print "*** Adding metadata to $final ***\n";
  my $xml = xml_credits($meta);
  if(defined $meta->{'title'}) {
    my $title = $meta->{'title'};
    $str .= " --artist \"$title\" --album \"$title\"" .
            " --albumArtist \"$title\" --TVShowName \"$title\""; }
  if(defined $meta->{'subtitle'}) {
    $str .= " --title \"$meta->{'subtitle'}\""; }
  if(defined $meta->{'category'}) {
    $str .= " --genre \"$meta->{'category'}\""; }
  if(defined $meta->{'originalairdate'}) {
    $str .= " --year $meta->{'originalairdate'}"; }
  if(defined $meta->{'description'}) {
    $str .= " --description \"$meta->{'description'}\""; }
  if(defined $meta->{'channel'}) {
    $str .= " --TVNetwork \"$meta->{'channel'}\""; }
  if(defined $meta->{'syndicatedepisodenumber'}) {
    $str .= " --TVEpisode \"$meta->{'syndicatedepisodenumber'}\""; }
  if(defined $meta->{'rating'}) {
    $str .= " --contentRating \"$meta->{'rating'}\""; }
  if(-e $files->{'video'} . ".png") {
    my $art = $files->{'video'} . ".png";
    $str .= " --artwork $art"; }
  $long_time =~ s/ /T/;
  $long_time .= "Z";
  $str .= " --stik \"TV Show\"";
  $str .= " --purchaseDate $long_time";
  $str .= " --encodingTool \"$versions\"";
  $str .= " --grouping \"MythTV Recording\"";
  if(defined $xml) {
    $str .= " --rDNSatom \"$xml\" name=iTunMOVI domain=com.apple.iTunes"; }
  system $str . " > /dev/null 2>&1";
  @tmp = glob "$file-temp-*.mp4";
  if(@tmp == 1) {
    move $tmp[0], $final;
    unlink $files->{'mp4'}; }
  else {
    die "Could not add metadata tags."; } }

sub generate_edl {
  my ($fps, $cutlist, $files) = @_;
  open my $file, '>', $files->{'edl'} or die "Could not write to EDL file.";
  for my $cut (@$cutlist) {
    my ($start, $end) = get_cut($cut);
    $start /= $fps;
    $end /= $fps;
    print $file "$start $end 0\n"; }
  close $file; }

die "Usage: $0 <channel> <start-time>" if @ARGV != 2;
add_trail_slash(\$tmp_dir);
add_trail_slash(\$final_dir);
my $channel = $ARGV[0];
my $time = $ARGV[1];
my $long_time = short_time_to_long($time);
my $video = get_path($channel, $long_time);
my ($fps, $duration, $res) = get_video_param($video);
my $base = $tmp_dir . $channel . "_" . $time;
my $fifo = $base . ".tmp";
my $srt = $base . ".srt";
my $edl = $base . ".edl";
my $h264 = $base . ".h264";
my $aac = $base . ".aac";
my $xml = $base . ".xml";
my $mp4 = $video;
$mp4 =~ s/\.[^.]*$//;
$mp4 .= ".mp4";
my $mp4tmp = $mp4 . "-tmp";
my %files = (video => $video, fifo => $fifo, srt => $srt, edl => $edl,
             h264 => $h264, aac => $aac, xml => $xml, mp4 => $mp4,
             mp4tmp => $mp4tmp, dir => $tmp_dir, finaldir => $final_dir);
print "*** Transcoding $video ***\n";
my @cutlist = get_cutlist($channel, $time);
my $seg = cut_and_transcode($duration, $fps, $res, \@cutlist, \%files);
remux($seg, \%files);
my $meta = get_metadata($channel, $time);
print_metadata($meta);
write_metadata($long_time, $meta, \%files);
clean_tmp(\%files, 0);

