Zachary Kotlarek 14 лет назад
Сommit
ee96295a20
29 измененных файлов с 3174 добавлено и 0 удалено
  1. BIN
      DVD-NoSleep.scpt
  2. BIN
      HandBrakeCLI
  3. BIN
      RiffScript.scpt
  4. 15 0
      checkTorrent
  5. 470 0
      encode.pl
  6. 9 0
      findAllTorrents
  7. 46 0
      findDate.sh
  8. 101 0
      findRecode
  9. 101 0
      findRecode0
  10. 653 0
      findTorrent.pl
  11. 49 0
      folderFixup.sh
  12. 47 0
      folderToMov.sh
  13. 6 0
      isMediaMounted
  14. 105 0
      movInfo.pl
  15. 53 0
      plex
  16. 53 0
      plex-button
  17. 23 0
      plex-checkTV
  18. 53 0
      plex-clean
  19. 53 0
      plex-files
  20. 18 0
      plex-filter
  21. 53 0
      plex-gui
  22. 53 0
      plex-playing
  23. 53 0
      plex-update
  24. 86 0
      recode
  25. 152 0
      renameFromDisk.pl
  26. 91 0
      timeout
  27. 50 0
      toMov.sh
  28. 139 0
      torrentMonitored.pl
  29. 642 0
      transmission.pl

BIN
DVD-NoSleep.scpt



BIN
RiffScript.scpt


+ 15 - 0
checkTorrent

@@ -0,0 +1,15 @@
+#!/bin/bash
+
+# Absolute paths for torrent app and bin
+TORRENT_APP="/Applications/Zach/Internet/Transmission.app"
+TORRENT_BIN="${TORRENT_APP}/Contents/MacOS/Transmission"
+
+# Bail if WoW is running
+if ps auwx | grep -v grep | grep -q "World of Warcraft/World of Warcraft.app/Contents/MacOS/World of Warcraft"; then
+	exit 0
+fi
+
+# Start the torrent app if it isn't running
+if ! ps auwx | grep -v grep | grep -q "${TORRENT_BIN}"; then
+	open "${TORRENT_APP}"
+fi

+ 470 - 0
encode.pl

@@ -0,0 +1,470 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+# Includes
+use IPC::Open3;
+use File::Basename;
+
+# Parameters
+my @video_params    = ('--markers', '--large-file', '--optimize', '--encoder', 'x264', '--detelecine', '--decomb', '--loose-anamorphic', '--modulus', '16', '--x264opts', 'b-adapt=2:rc-lookahead=50');
+my $FORMAT          = 'mp4';
+my $QUALITY         = 20;
+my $HD_QUALITY      = 20;
+my $HD_WIDTH        = 1350;
+my $MIN_VIDEO_WIDTH = 100;
+my $MAX_CROP_DIFF   = .1;
+my $MAX_DURA_DIFF   = 5;
+my $HAVE_DTS        = 0;
+my $HB_EXEC         = $ENV{'HOME'} . '/bin/video/HandBrakeCLI';
+my $DEBUG           = 0;
+
+# Runtime debug mode
+if (defined($ENV{'DEBUG'}) && $ENV{'DEBUG'}) {
+	$DEBUG = 1;
+}
+
+# Command-line parameters
+my ($in_file, $out_file, $title) = @ARGV;
+if (!defined($in_file) || length($in_file) < 1 || !-r $in_file) {
+	die('Usage: ' . basename($0) . " in_file [out_file] [title]\n");
+}
+
+# Scan for title/track info
+my %titles = &scan($in_file);
+
+# Allow external subtitles for single-title files
+if (scalar(keys(%titles)) == 1) {
+	my $srt_file = $in_file;
+	$srt_file =~ s/\.\w{2,3}$/.srt/;
+	if (-r $srt_file) {
+		if ($DEBUG) {
+			print STDERR 'Adding subtitles from: ' . $srt_file . "\n";
+		}
+		push(@video_params, '--srt-file', $srt_file);
+	}
+}
+
+# Allow encoding of a specific title
+if ($title) {
+	if ($title =~ /^\d+$/ && $title > 0) {
+		if (!defined($titles{$title})) {
+			die(basename($0) . ': Invalid title number: ' . $title . "\n");
+		}
+		my $selected_title = $titles{$title};
+		%titles = ();
+		$titles{$title} = $selected_title;
+	} elsif ($title =~ /main/i) {
+		my $max_title    = 0;
+		my $max_duration = 0;
+		foreach my $title (keys(%titles)) {
+			if (!$titles{$title}{'duration'}) {
+				warn(basename($0) . ': Unknown duration for title: ' . $title . "\n");
+				next;
+			}
+			my $new_max = 0;
+			if (abs($titles{$title}{'duration'} - $max_duration) < $MAX_DURA_DIFF) {
+				if ($titles{$title}{'aspect'} > $titles{$max_title}{'aspect'}) {
+					$new_max = 1;
+				} elsif ($titles{$title}{'size'}[0] > $titles{$max_title}{'size'}[0] || $titles{$title}{'size'}[1] > $titles{$max_title}{'size'}[1]) {
+					$new_max = 1;
+				}
+			} elsif ($titles{$title}{'duration'} > $max_duration) {
+				$new_max = 1;
+			}
+			if ($new_max) {
+				$max_duration = $titles{$title}{'duration'};
+				$max_title    = $title;
+			}
+		}
+		my $selected_title = $titles{$max_title};
+		%titles = ();
+		$titles{$max_title} = $selected_title;
+	}
+}
+
+# Ensure we have an output file name
+# Allow forced use of MKV wrapper if the output file name is provided and ends in .MKV
+if (!defined($out_file) || length($out_file) < 1) {
+	$out_file = $in_file;
+} else {
+	my ($force_format) = $out_file =~ /\.(\w{2,3})$/;
+	if (lc($force_format) eq 'mkv') {
+		$FORMAT = 'mkv';
+	}
+}
+
+# Encode each title
+foreach my $title (keys(%titles)) {
+	if ($DEBUG) {
+		print STDERR 'Setting options for title: ' . $title . "\n";
+	}
+
+	# Parse the title's audio and subtitle tracks
+	my $scan  = $titles{$title};
+	my @audio = &audioOptions($scan);
+	my @subs  = &subOptions($scan);
+
+	# Skip tracks that have no video
+	if (scalar(@{ $scan->{'size'} }) < 2 || $scan->{'size'}[0] < $MIN_VIDEO_WIDTH) {
+		print STDERR basename($0) . ': No video detected in: ' . $in_file . ':' . $title . ". Skipping title...\n";
+		next;
+	}
+
+	# Skip tracks that have no audio
+	if (scalar(@{ $scan->{'audio'} }) < 1) {
+		print STDERR basename($0) . ': No audio detected in: ' . $in_file . ':' . $title . ". Skipping title...\n";
+		next;
+	}
+
+	# Reduce the quality if the image size is particularly large
+	my $title_quality = $QUALITY;
+	if ($scan->{'size'}[0] > $HD_WIDTH) {
+		$title_quality = $HD_QUALITY;
+	}
+
+	# Override unlikely autocrop values
+	if (   abs($scan->{'crop'}[0] - $scan->{'crop'}[1]) > $scan->{'size'}[0] * $MAX_CROP_DIFF
+		|| abs($scan->{'crop'}[2] - $scan->{'crop'}[3]) > $scan->{'size'}[0] * $MAX_CROP_DIFF)
+	{
+		print STDERR basename($0) . ': Overriding unlikely autocrop values: ' . join(':', @{ $scan->{'crop'} }) . "\n";
+		my @crop = (0, 0, 0, 0);
+		$scan->{'crop'} = \@crop;
+	}
+
+	# Require the MKV format whenever we have DTS audio
+	my $title_format = $FORMAT;
+	if ($HAVE_DTS) {
+		$title_format = 'mkv';
+	}
+
+	# Select a file name extension that matches the format
+	my $title_out_file = $out_file;
+	$title_out_file =~ s/\.(?:\w{2,3}|dvdmedia)$//i;
+	if ($title_format eq 'mkv') {
+		$title_out_file .= '.mkv';
+	} else {
+		$title_out_file .= '.m4v';
+	}
+
+	# Force the title number into the output file name if there are multiple titles to be encoded
+	if (scalar(keys(%titles)) > 1) {
+		my $title_text = sprintf('%02d', $title);
+		$title_out_file =~ s/(\.\w{2,3})$/\-${title_text}${1}/;
+	}
+
+	# Sanity check
+	if (uc($title_out_file) eq uc($in_file)) {
+		$title_out_file =~ s/(\.\w{2,3})$/-recode${1}/;
+	}
+	if (-e $title_out_file) {
+		print STDERR basename($0) . ': Output file exists: ' . $title_out_file . ". Skipping...\n";
+		next;
+	}
+
+	# Build the arugment list
+	my @args = ($HB_EXEC);
+	push(@args, '--title',   $title);
+	push(@args, '--input',   $in_file);
+	push(@args, '--output',  $title_out_file);
+	push(@args, '--format',  $title_format);
+	push(@args, '--quality', $title_quality);
+	push(@args, '--crop',    join(':', @{ $scan->{'crop'} }));
+	push(@args, @video_params);
+	push(@args, @audio);
+	push(@args, @subs);
+
+	# Run it
+	if ($DEBUG) {
+		print STDERR join(' ', @args) . "\n";
+	}
+	my $child_out = '';
+	my $child_in  = '';
+	my $pid       = open3($child_in, $child_out, $child_out, @args);
+	close($child_in);
+	while (<$child_out>) {
+		if ($DEBUG) {
+			print STDERR $_;
+		}
+	}
+	waitpid($pid, 0);
+	close($child_out);
+
+	# Provide the new file name if requested
+	if ($ENV{'RECODE_OUTFILE'}) {
+		print $title_out_file;
+	}
+}
+
+# Cleanup
+exit(0);
+
+sub subOptions($) {
+	my ($scan) = @_;
+
+	# Find English subtitles
+	my @keep = ();
+	foreach my $track (@{ $scan->{'subtitle'} }) {
+		if (my ($lang) = $track->{'description'} =~ /\b(English|Unknown|Closed\s+Captions)\b/i) {
+			push(@keep, $track->{'index'});
+			if ($DEBUG) {
+				print STDERR 'Found ' . $lang . ' subtitle in track ' . $track->{'index'} . "\n";
+			}
+		}
+	}
+
+	# Send back the argument string (if any)
+	if (scalar(@keep) < 1) {
+		return '';
+	}
+	return ('--subtitle', join(',', @keep));
+}
+
+sub audioOptions($) {
+	my ($scan) = @_;
+
+	# Type the audio tracks
+	my $oat = undef();
+	my $aac = undef();
+	my $mpg = undef();
+	my @ac3 = ();
+	my @dts = ();
+	foreach my $track (@{ $scan->{'audio'} }) {
+		if ($track->{'description'} =~ /\bAC3\b/i) {
+			if (my ($lang) = $track->{'description'} =~ /\b(Chinese|Espanol|Francais|Japanese|Korean|Portugues|Thai)\b/i) {
+				if ($DEBUG) {
+					print STDERR 'Skipping AC3 in track ' . $track->{'index'} . ' due to language: ' . $lang . "\n";
+				}
+				next;
+			}
+			push(@ac3, $track->{'index'});
+			if ($DEBUG) {
+				print STDERR 'Found AC3 in track ' . $track->{'index'} . "\n";
+			}
+		} elsif ($track->{'description'} =~ /\bAAC\b/i) {
+			$aac = $track->{'index'};
+			if ($DEBUG) {
+				print STDERR 'Found AAC in track ' . $track->{'index'} . "\n";
+			}
+		} elsif ($track->{'description'} =~ /\bMP3\b/i || $track->{'description'} =~ /\bMPEG\b/i) {
+			$mpg = $track->{'index'};
+			if ($DEBUG) {
+				print STDERR 'Found MPEG/MP3 in track ' . $track->{'index'} . "\n";
+			}
+		} elsif ($track->{'description'} =~ /\bDTS\b/i) {
+			if ($track->{'description'} =~ /\bDTS\-MA\b/i) {
+				if ($DEBUG) {
+					print STDERR 'Skipping DTS-MA in track ' . $track->{'index'} . " due to poor DTS-MA support in HandBrake\n";
+				}
+				next;
+			}
+			push(@dts, $track->{'index'});
+			if ($DEBUG) {
+				print STDERR 'Found DTS in track ' . $track->{'index'} . "\n";
+			}
+		} elsif ($track->{'description'} =~ /\((?:MP2|PCM_[SF]\d+)\)/) {
+			$oat = $track->{'index'};
+			if ($DEBUG) {
+				print STDERR 'Found other audio (' . $track->{'description'} . ') in track ' . $track->{'index'} . "\n";
+			}
+		}
+	}
+
+	# Pick a stereo/mixdown plan based on the available track types
+	my $stereo  = undef();
+	my $mixdown = undef();
+	if (scalar(@dts) > 0) {
+		$mixdown = $dts[0];
+	} elsif (scalar(@ac3) > 0) {
+		$mixdown = $ac3[0];
+	} elsif (defined($aac) && $aac > 0) {
+		$stereo = $aac;
+	} elsif (defined($mpg) && $mpg > 0) {
+		$stereo = $mpg;
+	} elsif (defined($oat) && $oat > 0) {
+		$stereo = $oat;
+	}
+
+	# Sanity check
+	if (!(defined($mixdown) && $mixdown > 0) && !(defined($stereo) && $stereo > 0)) {
+		print STDERR basename($0) . ": No usable audio tracks in title\n";
+		return ();
+	}
+
+	# Stereo/mixdown track first
+	my @audio_tracks = ();
+	if (defined($mixdown) && $mixdown > 0) {
+		if ($DEBUG) {
+			print STDERR 'Mixing down track (track #' . $mixdown . ") to stereo\n";
+		}
+		my %track = ('index' => $mixdown, 'encoder' => 'ca_aac');
+		push(@audio_tracks, \%track);
+	} elsif (defined($stereo) && $stereo > 0) {
+		if ($DEBUG) {
+			print STDERR 'Using existing stereo track: ' . $stereo . "\n";
+		}
+		my %track = ('index' => $stereo, 'encoder' => 'ca_aac');
+		push(@audio_tracks, \%track);
+	}
+
+	# Always keep DTS if we found it
+	$HAVE_DTS = 0;
+	if (scalar(@dts) > 0) {
+		$HAVE_DTS = 1;
+		foreach my $dts_track (@dts) {
+			if ($DEBUG) {
+				print STDERR 'Keeping DTS passthru track: ' . $dts_track . "\n";
+			}
+			my %track = ('index' => $dts_track, 'encoder' => 'copy:dts');
+			push(@audio_tracks, \%track);
+		}
+	}
+
+	# Always keep AC3 if we found it (and liked it)
+	if (scalar(@ac3) > 0) {
+		foreach my $ac3_track (@ac3) {
+			if ($DEBUG) {
+				print STDERR 'Keeping AC3 passthru track: ' . $ac3_track . "\n";
+			}
+			my %track = ('index' => $ac3_track, 'encoder' => 'copy:ac3');
+			push(@audio_tracks, \%track);
+		}
+	}
+
+	# Consolidate from the hashes
+	my @tracks   = ();
+	my @encoders = ();
+	foreach my $track (@audio_tracks) {
+		push(@tracks,   $track->{'index'});
+		push(@encoders, $track->{'encoder'});
+	}
+
+	# Send back the argument strings
+	return ('--audio', join(',', @tracks), '--aencoder', join(',', @encoders));
+}
+
+sub scan($) {
+	my ($in_file) = @_;
+
+	# Fork to scan the file
+	my $child_out = '';
+	my $pid = open3('<&STDIN', $child_out, $child_out, $HB_EXEC, '--title', '0', '--input', $in_file);
+
+	# Loop through the output
+	my $scan;
+	my %titles       = ();
+	my $inTitle      = 0;
+	my $zone         = '';
+	my $dca_stream   = -1;
+	my %dca_lossless = ();
+	while (<$child_out>) {
+
+		if (!$inTitle && m/scan thread found (\d+) valid title/i) {
+			if ($DEBUG) {
+				print STDERR 'Found ' . $1 . " titles in source\n";
+			}
+		} elsif (m/^\s+Stream \#0\.(\d+)\(\w+\)\:\s+(.*)/) {
+			my $stream = $1;
+			my $desc   = $2;
+			$dca_stream = -1;
+			if ($desc =~ /Audio\:\s+dca/) {
+				$dca_stream = $stream;
+			}
+		} elsif ($dca_stream >= 0 && m/^\s+title\s*\:\s+(.*)/) {
+			if ($1 =~ /Lossless/i) {
+				$dca_lossless{$dca_stream} = 1;
+				if ($DEBUG) {
+					print STDERR 'Found DTS Lossless audio in stream: ' . $dca_stream . "\n";
+				}
+			}
+			$dca_stream = -1;
+		} elsif (m/^\s*\+\s+title\s+(\d+)\:/) {
+
+			# Save the current title (if any)
+			if ($inTitle) {
+				$titles{$inTitle} = $scan;
+			}
+
+			# Grab the new title number
+			$inTitle = $1;
+
+			# Init the data collectors
+			my @audio    = ();
+			my @crop     = ();
+			my @subtitle = ();
+			my @size     = ();
+			my %tmp      = (
+				'audio'    => \@audio,
+				'crop'     => \@crop,
+				'subtitle' => \@subtitle,
+				'size'     => \@size,
+				'duration' => 0,
+				'aspect'   => 0
+			);
+			$scan = \%tmp;
+
+			if ($DEBUG) {
+				print STDERR 'Found data for title ' . $1 . "\n";
+			}
+		} elsif ($inTitle) {
+			if (/^\s*\+\s+size: (\d+)x(\d+)/) {
+				push(@{ $scan->{'size'} }, $1, $2);
+				if ($DEBUG) {
+					print STDERR 'Size: ' . join('x', @{ $scan->{'size'} }) . "\n";
+				}
+				if (/\s+display aspect\: (\d*\.\d+)/) {
+					$scan->{'aspect'} = $1;
+					if ($DEBUG) {
+						print STDERR 'Aspect: ' . $scan->{'aspect'} . "\n";
+					}
+				}
+			} elsif (/^\s*\+\s+duration\:\s+(\d+)\:(\d+)\:(\d+)/) {
+				$scan->{'duration'} = ($1 * 3600) + ($2 * 60) + $3;
+				if ($DEBUG) {
+					print STDERR 'Duration: ' . $scan->{'duration'} . "\n";
+				}
+			} elsif (/^\s*\+\s+autocrop\:\s+(\d+)\/(\d+)\/(\d+)\/(\d+)/) {
+				push(@{ $scan->{'crop'} }, $1, $2, $3, $4);
+				if ($DEBUG) {
+					print STDERR 'Crop: ' . join('/', @{ $scan->{'crop'} }) . "\n";
+				}
+			} elsif ($zone ne 'audio' && /^\s*\+\s+audio\s+tracks\:\s*$/) {
+				$zone = 'audio';
+			} elsif ($zone eq 'audio' && /^\s*\+\s+(\d+)\,\s+(.*)/) {
+				my %track = ('index' => $1, 'description' => $2);
+				if ($dca_lossless{ $track{'index'} }) {
+					$track{'description'} =~ s/\(DTS\)/\(DTS-MA\)/;
+				}
+				push(@{ $scan->{'audio'} }, \%track);
+				if ($DEBUG) {
+					print STDERR 'Audio Track #' . $track{'index'} . ': ' . $track{'description'} . "\n";
+				}
+			} elsif ($zone ne 'subtitle' && /^\s*\+\s+subtitle\s+tracks\:\s*$/) {
+				$zone = 'subtitle';
+			} elsif ($zone eq 'subtitle' && m/^\s*\+\s+(\d+)\,\s+(.*)$/) {
+				my %track = ('index' => $1, 'description' => $2);
+				push(@{ $scan->{'subtitle'} }, \%track);
+				if ($DEBUG) {
+					print STDERR 'Subtitle Track #' . $track{'index'} . ': ' . $track{'description'} . "\n";
+				}
+			}
+		}
+	}
+
+	# Save the last title (if any)
+	if ($inTitle) {
+		$titles{$inTitle} = $scan;
+	}
+
+	# Cleanup the scan process
+	waitpid($pid, 0);
+	close($child_out);
+
+	# Sanity check
+	if (scalar(keys(%titles)) < 1) {
+		die(basename($0) . ': Did not find any titles in file: ' . $in_file . "\n");
+	}
+
+	# Return
+	return %titles;
+}

+ 9 - 0
findAllTorrents

@@ -0,0 +1,9 @@
+#!/bin/bash
+
+# Bail if the media share isn't available
+if ! ~/bin/video/isMediaMounted; then
+        exit 0
+fi
+
+# Run a search for each monitored series
+~/bin/video/torrentMonitored.pl null | xargs -n 1 -0 ~/bin/video/findTorrent.pl

+ 46 - 0
findDate.sh

@@ -0,0 +1,46 @@
+#!/bin/bash
+set -e
+
+# Parameters
+TV_DIR="/Volumes/media/TV"
+SERIES="${1}"
+SEARCH="${2}"
+DAYS_BACK="${3}"
+MAX_DAYS_BACK="${4}"
+
+# Usage
+if [ -z "${SERIES}" ] || [ -z "${SEARCH}" ] || [ -z "${DAYS_BACK}" ] || \
+	! echo "${DAYS_BACK}" | grep -qE '^[0-9]*$' || [ $DAYS_BACK -lt 1 ] || [ $DAYS_BACK -gt 90 ]; then
+		echo "Usage: `basename "${0}" series search days_back [max_days_back]`" 1>&2
+		exit 1
+fi
+
+# Allow use in recursive mode
+if [ -n "${MAX_DAYS_BACK}" ]; then
+	if ! echo "${MAX_DAYS_BACK}" | grep -qE '^[0-9]*$' || [ $MAX_DAYS_BACK -lt $DAYS_BACK ] || [ $MAX_DAYS_BACK -gt 90 ]; then
+		echo "Usage: `basename "${0}" series search days_back [max_days_back]`" 1>&2
+		exit 1
+	fi
+	while [ $DAYS_BACK -le $MAX_DAYS_BACK ]; do
+		"${0}" "${SERIES}" "${SEARCH}" "${DAYS_BACK}"
+		DAYS_BACK=$(( $DAYS_BACK + 1 ))
+	done
+	exit 0
+fi
+
+# Calculate some dates
+YEAR="`date -v-${DAYS_BACK}d '+%Y'`"
+MONTH="`date -v-${DAYS_BACK}d '+%m'`"
+DAY="`date -v-${DAYS_BACK}d '+%d'`"
+
+# Bail if we already have a matcing file
+FILES="`find "${TV_DIR}/${SERIES}/Season ${YEAR}" -type f -not -name '*.nfo' -not -name '*.tbn' -name "${YEAR}-${MONTH}-${DAY} - *"`"
+if [ -n "${FILES}" ]; then
+	exit 0
+fi
+
+# Otherwise run a search
+SEARCH="`echo "${SEARCH}" | sed "s/%Y/${YEAR}/g"`"
+SEARCH="`echo "${SEARCH}" | sed "s/%m/${MONTH}/g"`"
+SEARCH="`echo "${SEARCH}" | sed "s/%d/${DAY}/g"`"
+~/bin/video/findTorrent.pl "${SERIES}" "${SEARCH}"

+ 101 - 0
findRecode

@@ -0,0 +1,101 @@
+#!/bin/bash
+
+# Parameters
+FOLDER="/mnt/media/TV"
+MIN_RATE=275000
+MIN_SIZE="425M"
+MIN_HEIGHT=500
+NAME_REGEX="\.(mov|avi|mkv|mp4|ts)$"
+
+# Command-line overrides
+if [ -n "${1}" ]; then
+	FOLDER="${1}"
+fi
+if [ -n "${2}" ]; then
+	MIN_SIZE="${2}"
+fi
+if [ -n "${3}" ]; then
+	MIN_RATE="${3}"
+fi
+if [ -n "${4}" ]; then
+	MIN_HEIGHT="${4}"
+fi
+if [ -n "${5}" ]; then
+	NAME_REGEX="${5}"
+fi
+
+# Name-based paramters
+ZERO=0
+if basename "${0}" | grep -q '0'; then
+	ZERO=1
+fi
+
+# Sanity check
+if [ ! -d "${FOLDER}" ]; then
+	echo "Usage: `basename "${0}"` [folder] [min_size] [min_rate] [min_height] [name_regex]" 1>&2
+	exit 1
+fi
+
+# Find large video files
+# Later tests exclude Handbrake-encoded files
+FILES="`find "${FOLDER}" -maxdepth 1 -size "+${MIN_SIZE}" | grep -iE "${NAME_REGEX}"`"
+
+# Loop with newline-as-IFS
+OLDIFS="${IFS}"
+IFS=$'\n'
+for i in ${FILES}; do
+
+	# Skip invalid files
+	if [ -z "${i}" ] || [ ! -r "${i}" ]; then
+		continue
+	fi
+
+	# We do not want to recode videos that we already encoded
+	HANDBRAKE=0
+	STRINGS="`head -c 10000000 "${i}" | strings -n 10`"
+	if echo "${STRINGS}" | grep -q HandBrake; then
+		HANDBRAKE=1
+	elif echo "${STRINGS}" | grep -E '^x264 - core (79|112)' | grep -E 'threads=(12|24)' | grep -Eq 'crf=2[05].0'; then
+		HANDBRAKE=1
+	fi
+	if [ $HANDBRAKE -eq 1 ]; then
+		continue
+	fi
+
+	# We only care about "big" videos
+	# Movies with no known height should be left alone
+	# Movies with a "0" height can be assumed to be tall enough
+	HEIGHT="`~/bin/video/movInfo.pl "${i}" VIDEO_HEIGHT 2>/dev/null`"
+	if [ -z "${HEIGHT}" ]; then
+		HEIGHT=1
+	fi
+	if [ $HEIGHT -eq 0 ]; then
+		HEIGHT=$MIN_HEIGHT
+	fi
+	if [ $HEIGHT -lt $MIN_HEIGHT ]; then
+		continue
+	fi
+
+	# Skip files is low bitrates
+	SIZE="`stat -f %z "${i}" 2>/dev/null`"
+	if [ -z "${SIZE}" ]; then
+		SIZE=1
+	fi
+	LENGTH="`~/bin/video/movInfo.pl "${i}" LENGTH 2>/dev/null | sed 's%\..*$%%'`"
+	if [ -z "${LENGTH}" ] || [ $LENGTH -eq 0 ]; then
+		LENGTH=1
+	fi
+	RATE=$(( $SIZE / $LENGTH ))
+	if [ $RATE -le $MIN_RATE ]; then
+		continue
+	fi
+
+
+	# If we're still around, the file matched all criteria
+	if [ $ZERO -gt 0 ]; then
+		echo -en "${i}\0"
+	else
+		echo "${i}"
+	fi
+done
+IFS="${OLDIFS}"

+ 101 - 0
findRecode0

@@ -0,0 +1,101 @@
+#!/bin/bash
+
+# Parameters
+FOLDER="/mnt/media/TV"
+MIN_RATE=275000
+MIN_SIZE="425M"
+MIN_HEIGHT=500
+NAME_REGEX="\.(mov|avi|mkv|mp4|ts)$"
+
+# Command-line overrides
+if [ -n "${1}" ]; then
+	FOLDER="${1}"
+fi
+if [ -n "${2}" ]; then
+	MIN_SIZE="${2}"
+fi
+if [ -n "${3}" ]; then
+	MIN_RATE="${3}"
+fi
+if [ -n "${4}" ]; then
+	MIN_HEIGHT="${4}"
+fi
+if [ -n "${5}" ]; then
+	NAME_REGEX="${5}"
+fi
+
+# Name-based paramters
+ZERO=0
+if basename "${0}" | grep -q '0'; then
+	ZERO=1
+fi
+
+# Sanity check
+if [ ! -d "${FOLDER}" ]; then
+	echo "Usage: `basename "${0}"` [folder] [min_size] [min_rate] [min_height] [name_regex]" 1>&2
+	exit 1
+fi
+
+# Find large video files
+# Later tests exclude Handbrake-encoded files
+FILES="`find "${FOLDER}" -maxdepth 1 -size "+${MIN_SIZE}" | grep -iE "${NAME_REGEX}"`"
+
+# Loop with newline-as-IFS
+OLDIFS="${IFS}"
+IFS=$'\n'
+for i in ${FILES}; do
+
+	# Skip invalid files
+	if [ -z "${i}" ] || [ ! -r "${i}" ]; then
+		continue
+	fi
+
+	# We do not want to recode videos that we already encoded
+	HANDBRAKE=0
+	STRINGS="`head -c 10000000 "${i}" | strings -n 10`"
+	if echo "${STRINGS}" | grep -q HandBrake; then
+		HANDBRAKE=1
+	elif echo "${STRINGS}" | grep -E '^x264 - core (79|112)' | grep -E 'threads=(12|24)' | grep -Eq 'crf=2[05].0'; then
+		HANDBRAKE=1
+	fi
+	if [ $HANDBRAKE -eq 1 ]; then
+		continue
+	fi
+
+	# We only care about "big" videos
+	# Movies with no known height should be left alone
+	# Movies with a "0" height can be assumed to be tall enough
+	HEIGHT="`~/bin/video/movInfo.pl "${i}" VIDEO_HEIGHT 2>/dev/null`"
+	if [ -z "${HEIGHT}" ]; then
+		HEIGHT=1
+	fi
+	if [ $HEIGHT -eq 0 ]; then
+		HEIGHT=$MIN_HEIGHT
+	fi
+	if [ $HEIGHT -lt $MIN_HEIGHT ]; then
+		continue
+	fi
+
+	# Skip files is low bitrates
+	SIZE="`stat -f %z "${i}" 2>/dev/null`"
+	if [ -z "${SIZE}" ]; then
+		SIZE=1
+	fi
+	LENGTH="`~/bin/video/movInfo.pl "${i}" LENGTH 2>/dev/null | sed 's%\..*$%%'`"
+	if [ -z "${LENGTH}" ] || [ $LENGTH -eq 0 ]; then
+		LENGTH=1
+	fi
+	RATE=$(( $SIZE / $LENGTH ))
+	if [ $RATE -le $MIN_RATE ]; then
+		continue
+	fi
+
+
+	# If we're still around, the file matched all criteria
+	if [ $ZERO -gt 0 ]; then
+		echo -en "${i}\0"
+	else
+		echo "${i}"
+	fi
+done
+IFS="${OLDIFS}"

+ 653 - 0
findTorrent.pl

@@ -0,0 +1,653 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+my $DEBUG               = 0;
+my $NO_QUALITY_CHECKS   = 0;
+my $MORE_NUMBER_FORMATS = 0;
+
+# Includes
+use JSON;
+use File::Temp;
+use File::Basename;
+use lib '/usr/local/spidering/bots/utils';
+use Fetch;
+
+# Command line
+my ($dir, $search) = @ARGV;
+if (!defined($dir) || (!defined($search) && !-d $dir)) {
+	die('Usage: ' . basename($0) . " input_directory [search_string]\n");
+}
+if ($ENV{'DEBUG'}) {
+	$DEBUG = 1;
+}
+if ($ENV{'NO_QUALITY_CHECKS'}) {
+	$NO_QUALITY_CHECKS = 1;
+}
+if ($ENV{'MORE_NUMBER_FORMATS'}) {
+	$MORE_NUMBER_FORMATS = 1;
+}
+
+# Parameters
+my $DELAY            = 4;
+my $MIN_COUNT        = 10;
+my $MIN_SIZE         = 100;
+my $SIZE_BONUS       = 5;
+my $SIZE_PENALTY     = $SIZE_BONUS;
+my $MAX_SEED_RATIO   = .25;
+my $SEED_RATIO_COUNT = 10;
+my $PROTOCOL         = 'http';
+my $UA               = 'Mozilla/5.0 (Macintosh; U; PPC Mac OS X 10_5_5; en-us) AppleWebKit/525.18 (KHTML, like Gecko) Version/3.1.2 Safari/525.20.1';
+
+# Search sources
+my %SOURCES = ();
+{
+	my %tmp = (
+		'search_url'    => 'thepiratebay.se/search/',
+		'search_suffix' => '/0/7/0',
+		'weight'        => 1.5,
+		'quote'         => '0'
+	);
+	$SOURCES{'TPB'} = \%tmp;
+}
+{
+	my %tmp = (
+		'search_url'    => 'isohunt.com/js/json.php?rows=10&start=1&ihq=',
+		'search_suffix' => '',
+		'weight'        => 0.30,
+		'quote'         => 1
+	);
+
+	#$SOURCES{'ISO'} = \%tmp;
+}
+
+# Environment
+#if ($DEBUG) {
+#	print STDERR `printenv` . "\n";
+#}
+
+# New fetch object
+my $cookies = mktemp('/tmp/findTorrent.cookies.XXXXXXXX');
+my $fetch   = Fetch->new(
+	'cookiefile' => $cookies,
+	'uas'        => $UA
+);
+
+# Get the season name
+my $show          = '';
+my @urls          = ();
+my $CUSTOM_SEARCH = 0;
+my $season        = 0;
+my @need          = ();
+my %need          = ();
+if (defined($search) && length($search) > 0) {
+
+	# Note the custom search string
+	$CUSTOM_SEARCH = 1;
+
+	# Create the relevent search strings
+	foreach my $key (keys(%SOURCES)) {
+		my $source = $SOURCES{$key};
+		push(@urls, $PROTOCOL . '://' . $source->{'search_url'} . $search . $source->{'search_suffix'});
+	}
+
+	# Get and clean the show name (for post-search matching)
+	$show = $dir;
+	$show =~ s/[\'\"]//g;
+} else {
+
+	# Read the input directory
+	if ($DEBUG) {
+		print STDERR 'Checking directory: ' . $dir . "\n";
+	}
+
+	# Allow quality checks to be disabled
+	if (-e dirname($dir) . '/no_quality_checks') {
+		$NO_QUALITY_CHECKS = 1;
+	}
+
+	# Allow use of more number formats
+	if (-e dirname($dir) . '/more_number_formats') {
+		$MORE_NUMBER_FORMATS = 1;
+	}
+
+	# Get and clean the show name
+	$show = basename(dirname($dir));
+	$show =~ s/[\'\"]//g;
+
+	# Allow the show name to be overriden
+	{
+		my $search_name = dirname($dir) . '/search_name';
+		if (-e $search_name) {
+			local ($/, *FH);
+			open(FH, $search_name)
+			  or die('Unable to read search_name for show: ' . $show . ': ' . $! . "\n");
+			my $text = <FH>;
+			close(FH);
+			if ($text =~ /^\s*(\S.*\S)\s*$/) {
+				$show = $1;
+			} else {
+				print STDERR 'Skipping invalid search_name for show: ' . $show . ': ' . $text . "\n";
+			}
+		}
+	}
+	if ($DEBUG) {
+		print STDERR 'Searching with series title: ' . $show . "\n";
+	}
+
+	# Get the season number
+	my $name = basename($dir);
+	if ($name =~ /^\s*Season\s+(\d+)\s*$/i) {
+		$season = $1;
+	}
+	if (!defined($season) || $season < 1 || $season > 2000) {
+		if (defined($season)) {
+			if ($DEBUG) {
+				print STDERR 'Skipping season ' . $season . "\n";
+			}
+			exit 0;
+		}
+		die('Invalid season number: ' . $show . ' => ' . $name . "\n");
+	}
+
+	# Get the last episode number
+	my $no_next  = 0;
+	my %episodes = ();
+	my $highest  = 0;
+	opendir(SEASON, $dir)
+	  or die("Unable to open season directory: ${!}\n");
+	while (my $file = readdir(SEASON)) {
+
+		# Skip support files
+		if ($file =~ /\.(?:png|xml|jpg|gif|tbn|txt|nfo|torrent)\s*$/i) {
+			next;
+		}
+
+		# Check for a season_done file
+		if ($file eq 'season_done') {
+			$no_next = 1;
+			next;
+		}
+
+		# Extract the episode number
+		my ($num) = $file =~ /^\s*\.?(\d+)\s*\-\s*/i;
+		if (defined($num)) {
+			$num = int($num);
+			if ($DEBUG) {
+				print STDERR 'Found episode number: ' . $num . ' in file: ' . $file . "\n";
+			}
+
+			# Record it
+			$episodes{$num} = 1;
+
+			# Track the highest episode number
+			if ($num > $highest) {
+				$highest = $num;
+			}
+		}
+	}
+	close(SEASON);
+
+	# Assume we need the next 2 episodes, unless no_next is set (i.e. season_done)
+	if (!$no_next) {
+		@need = ($highest + 1, $highest + 2);
+	}
+
+	# Find any missing episodes
+	for (my $i = 1 ; $i <= $highest ; $i++) {
+		if (!$episodes{$i}) {
+			push(@need, $i);
+		}
+	}
+	if ($DEBUG) {
+		print STDERR 'Needed episodes: ' . join(', ', @need) . "\n";
+	}
+
+	# Reverse the array for later matching
+	foreach my $episode (@need) {
+		$need{$episode} = 1;
+	}
+
+	# Check for URL files
+	if (-e "${dir}/url") {
+		open(URL, "${dir}/url")
+		  or die("Unable to open URLs file: ${!}\n");
+		while (<URL>) {
+			if (/^\s*[a-z]+(\:\S+)\s*$/) {
+				push(@urls, $PROTOCOL . $1);
+			}
+		}
+		close(URL);
+	}
+
+	# Construct a URL-friendly show name
+	my $safeShow = $show;
+	$safeShow =~ s/\s+\&\s+/ and /i;
+	$safeShow =~ s/^\s*The\b//i;
+	$safeShow =~ s/[\W_]+/ /g;
+	$safeShow =~ s/^\s+//;
+	$safeShow =~ s/\s+$//;
+	$safeShow =~ s/([^A-Za-z0-9])/sprintf("%%%02X", ord($1))/seg;
+	$safeShow =~ s/\%20/\+/g;
+
+	# Calculate possible name variations
+	my @urlShowVarients = ();
+	{
+
+		# Default name
+		push(@urlShowVarients, $safeShow);
+
+		# Search for both "and" and "&"
+		if ($safeShow =~ /\+and\+/i) {
+			my $tmp = $safeShow;
+			$tmp =~ s/\+and\+/\+%26\+/;
+			push(@urlShowVarients, $tmp);
+		}
+	}
+
+	# Construct the URL for each title varient of each needed episode
+	foreach my $urlShow (@urlShowVarients) {
+		foreach my $episode (@need) {
+			my $episode_long = sprintf('%02d', $episode);
+			my $season_long  = sprintf('%02d', $season);
+			foreach my $source (values(%SOURCES)) {
+				my $quote = '%22';
+				if (!$source->{'quote'}) {
+					$quote = '';
+				}
+
+				# SXXEYY
+				my $url = $PROTOCOL . '://' . $source->{'search_url'} . $quote . $urlShow . $quote . '+s' . $season_long . 'e' . $episode_long;
+				if ($source->{'search_suffix'}) {
+					$url .= $source->{'search_suffix'};
+				}
+				push(@urls, $url);
+
+				# Extra searches for shows that have lazy/non-standard number formats
+				if ($MORE_NUMBER_FORMATS) {
+
+					# SXEY
+					if ($season_long ne $season || $episode_long ne $episode) {
+						$url = $PROTOCOL . '://' . $source->{'search_url'} . $quote . $urlShow . $quote . '+s' . $season . 'e' . $episode;
+						if ($source->{'search_suffix'}) {
+							$url .= $source->{'search_suffix'};
+						}
+					}
+					push(@urls, $url);
+
+					# SXX EYY
+					$url = $PROTOCOL . '://' . $source->{'search_url'} . $quote . $urlShow . $quote . '+s' . $season . '+e' . $episode;
+					if ($source->{'search_suffix'}) {
+						$url .= $source->{'search_suffix'};
+					}
+					push(@urls, $url);
+
+					# Season XX Episode YY
+					$url = $PROTOCOL . '://' . $source->{'search_url'} . $quote . $urlShow . $quote . '+season+' . $season . '+episode+' . $episode;
+					if ($source->{'search_suffix'}) {
+						$url .= $source->{'search_suffix'};
+					}
+					push(@urls, $url);
+
+				}
+			}
+		}
+	}
+}
+
+my $content      = '';
+my @json_content = ();
+foreach my $url (@urls) {
+
+	# Fetch the page
+	if ($DEBUG) {
+		print STDERR 'Searching with URL: ' . $url . "\n";
+		$fetch->file('/tmp/findTorrent-lastPage.html');
+	}
+	sleep($DELAY * (rand(2) + 0.5));
+	$fetch->url($url);
+	$fetch->fetch();
+
+	# Check for errors
+	if ($fetch->status_code() != 200) {
+		print STDERR 'Error fetching URL: ' . $url . "\n";
+	}
+
+	# Save and compact the content
+	if ($DEBUG) {
+		print STDERR 'Fetched ' . length($fetch->content()) . " bytes\n";
+	}
+	if ($fetch->content() =~ /^\s*{/) {
+		my $json = eval { decode_json($fetch->content()); };
+		if (defined($json) && ref($json)) {
+			push(@json_content, \@{ $json->{'items'}->{'list'} });
+		} elsif ($DEBUG) {
+			print STDERR 'JSON parsing failure on: ' . $fetch->content() . "\n";
+		}
+	} else {
+		$content .= ' ' . $fetch->content();
+		$content =~ s/\s+/ /g;
+	}
+}
+
+# Find each TR element in TPB content
+my @tors = ();
+my @trs = split(/\<tr(?:\s+[^\>]*)?\>/i, $content);
+if ($DEBUG) {
+	print STDERR 'TR count: ' . scalar(@trs) . "\n";
+}
+foreach my $tr (@trs) {
+
+	# Trim trailing tags
+	if (!($tr =~ s/\<\/tr\>.*$//is)) {
+
+		# Skip things that aren't compelete TRs
+		if ($DEBUG > 1) {
+			print STDERR 'Skipping non-TR line: ' . $tr . "\n\n";
+		}
+		next;
+	}
+
+	# Find the show title
+	my ($title) = $tr =~ /title\=\"Details\s+for\s+([^\"]*)\"/i;
+	if (!defined($title) || length($title) < 1) {
+		if ($DEBUG) {
+			print STDERR "Unable to find show title in TR\n";
+		}
+		next;
+	}
+	$title =~ s/^\s+//;
+
+	# Extract the season number
+	my $isWholeSeason = 0;
+	my $fileSeason    = 0;
+	if ($title =~ /s0?([1-9]\d*)/i) {
+		$fileSeason = $1;
+		if ((!defined($fileSeason) || $season < 1) && $title =~ /season\s*0?([1-9]\d*)/i) {
+			$fileSeason    = $1;
+			$isWholeSeason = 1;
+		}
+	}
+
+	# Extract the episode number
+	my $episode = 0;
+	if ($isWholeSeason) {
+		$episode = $need[0];
+	} elsif ($title =~ /e0*([1-9]\d*)/i) {
+		$episode = $1;
+	}
+
+	# Extract the URL
+	my ($url) = $tr =~ /\<a\s+href\=\"(magnet\:\?[^\"]+)\"/i;
+	if (!defined($url) || length($url) < 1) {
+		if ($DEBUG) {
+			print STDERR "Skipping TR with no magnet URL\n";
+		}
+		next;
+	}
+
+	# Count the sum of seeders and leachers
+	my $seeds   = 0;
+	my $leaches = 0;
+	if ($tr =~ /\<td(?:\s+[^\>]*)?\>(\d+)\<\/td\>\s*\<td(?:\s+[^\>]*)?\>(\d+)\<\/td\>\s*$/i) {
+		$seeds   = $1;
+		$leaches = $2;
+	}
+
+	# Extract the size
+	my $size = 0;
+	my $unit = 'M';
+	if ($tr =~ m/ Size (\d+\.\d+)\&nbsp\;(G|M)iB/) {
+		$size = $1;
+		$unit = $2;
+		if ($unit eq 'G') {
+			$size *= 1024;
+		}
+	}
+	$size = int($size);
+
+	if ($DEBUG) {
+		print STDERR 'Found file (' . $title . '): ' . $url . "\n";
+	}
+
+	# Save the extracted data
+	my %tor = (
+		'title'     => $title,
+		'is_season' => $isWholeSeason,
+		'season'    => $fileSeason,
+		'episode'   => $episode,
+		'seeds'     => $seeds,
+		'leaches'   => $leaches,
+		'size'      => $size,
+		'url'       => $url,
+		'source'    => 'TPB'
+	);
+	push(@tors, \%tor);
+}
+
+# Find each discrete torrent from ISOhunt
+foreach my $json (@json_content) {
+	foreach my $tor (@{$json}) {
+
+		# Extract the title
+		my $title = $tor->{'title'};
+		$title =~ s/\<\/?b\>//gi;
+		$title =~ s/^\s+//;
+
+		# Extract the season number
+		my $isWholeSeason = 0;
+		my $fileSeason    = 0;
+		if ($tor->{'title'} =~ /s0?([1-9]\d*)/i) {
+			$fileSeason = $1;
+			if ((!defined($fileSeason) || $season < 1) && $tor->{'title'} =~ /season\s*0?([1-9]\d*)/i) {
+				$fileSeason    = $1;
+				$isWholeSeason = 1;
+			}
+		}
+
+		# Extract the episode number
+		my $episode = 0;
+		if ($isWholeSeason) {
+			$episode = $need[0];
+		} elsif ($tor->{'title'} =~ /e0*([1-9]\d*)/i) {
+			$episode = $1;
+		}
+
+		# Count the sum of seeders and leachers
+		my $seeds   = 0;
+		my $leaches = 0;
+		if (defined($tor->{'Seeds'}) && length($tor->{'Seeds'}) > 0) {
+			$seeds = $tor->{'Seeds'};
+		}
+		if (defined($tor->{'leechers'}) && length($tor->{'leechers'}) > 0) {
+			$leaches = $tor->{'leechers'};
+		}
+
+		# Extract the size
+		my $size = 0;
+		if (defined($tor->{'length'}) && length($tor->{'length'}) > 0) {
+			$size = int($tor->{'length'} / 1048576);
+		}
+
+		if ($DEBUG) {
+			print STDERR 'Found file (' . $title . '): ' . $tor->{'enclosure_url'} . "\n";
+		}
+
+		# Save the extracted data
+		my %tmp = (
+			'title'     => $title,
+			'is_season' => $isWholeSeason,
+			'season'    => $fileSeason,
+			'episode'   => $episode,
+			'seeds'     => $seeds,
+			'leaches'   => $leaches,
+			'size'      => $size,
+			'url'       => $tor->{'enclosure_url'},
+			'source'    => 'ISO'
+		);
+		push(@tors, \%tmp);
+	}
+}
+
+# Filter for size/count/etc.
+my %tors      = ();
+my $showRegex = $show;
+$showRegex =~ s/[\W_]+/\[\\W_\].*/g;
+foreach my $tor (@tors) {
+
+	# Skip files that don't start with our show title
+	if (!($tor->{'title'} =~ /^${showRegex}\b/i)) {
+		if ($DEBUG) {
+			print STDERR 'Skipping file: Title does not match (' . $showRegex . '): ' . $tor->{'url'} . "\n";
+		}
+		next;
+
+		# Skip pre-air files
+	} elsif ($tor->{'title'} =~ /preair/i) {
+		if ($DEBUG) {
+			print STDERR 'Skipping file: Title contains "preair": ' . $tor->{'url'} . "\n";
+		}
+		next;
+
+		# Skip SWESUB files
+	} elsif ($tor->{'title'} =~ /swesub/i) {
+		if ($DEBUG) {
+			print STDERR 'Skipping file: Title contains "SWESUB": ' . $tor->{'url'} . "\n";
+		}
+		next;
+
+		# Skip .rus. files
+	} elsif ($tor->{'title'} =~ /\.rus\./i) {
+		if ($DEBUG) {
+			print STDERR 'Skipping file: Title contains ".rus.": ' . $tor->{'url'} . "\n";
+		}
+		next;
+
+		# Skip german files
+	} elsif ($tor->{'title'} =~ /german/i) {
+		if ($DEBUG) {
+			print STDERR 'Skipping file: Title contains "german": ' . $tor->{'url'} . "\n";
+		}
+		next;
+
+		# Skip torrents that are too small
+	} elsif ($tor->{'size'} < $MIN_SIZE) {
+		if ($DEBUG) {
+			print STDERR 'Skipping file: Insufficient size (' . $tor->{'size'} . ' MiB): ' . $tor->{'url'} . "\n";
+		}
+		next;
+	}
+
+	# Only apply season and episode number matches if CUSTOMER_SEARCH is not in effect
+	if (!$CUSTOM_SEARCH) {
+
+		# Skip files that don't contain the right season number
+		if (!$tor->{'is_season'} && (!defined($tor->{'season'}) || $tor->{'season'} != $season)) {
+			if ($DEBUG) {
+				print STDERR 'Skipping file: No match for season number (' . $season . '): ' . $tor->{'url'} . "\n";
+			}
+			next;
+
+			# Skip files that don't contain the episode number
+		} elsif ((!defined($tor->{'episode'}) || !$need{ $tor->{'episode'} })) {
+			if ($DEBUG) {
+				print STDERR 'Skipping file: No match for episode number (' . $tor->{'episode'} . '): ' . $tor->{'url'} . "\n";
+			}
+			next;
+		}
+	}
+
+	# Only apply the quality rules if NO_QUALITY_CHECKS is not in effect
+	if (!$NO_QUALITY_CHECKS) {
+
+		# Skip torrents with too few seeders/leachers
+		if (($tor->{'seeds'} + $tor->{'leaches'}) * $SOURCES{ $tor->{'source'} }->{'weight'} < $MIN_COUNT) {
+			if ($DEBUG) {
+				print STDERR 'Skipping file: Insufficient seeder/leacher count (' . $tor->{'seeds'} . '/' . $tor->{'leaches'} . '): ' . $tor->{'url'} . "\n";
+			}
+			next;
+
+			# Skip torrents with unusual seed/leach ratios
+		} elsif ($tor->{'seeds'} > 1
+			&& $tor->{'seeds'} < $SEED_RATIO_COUNT
+			&& $tor->{'seeds'} > $tor->{'leaches'} * $MAX_SEED_RATIO)
+		{
+			if ($DEBUG) {
+				print STDERR 'Skipping file: Unusual seeder/leacher ratio (' . $tor->{'seeds'} . '/' . $tor->{'leaches'} . '): ' . $tor->{'url'} . "\n";
+			}
+			next;
+
+		}
+	}
+
+	# Save good torrents
+	push(@{ $tors{ $tor->{'episode'} } }, $tor);
+	if ($DEBUG) {
+		print STDERR 'Possible URL (' . $tor->{'seeds'} . '/' . $tor->{'leaches'} . ' seeds/leaches, ' . $tor->{'size'} . ' MiB): ' . $tor->{'url'} . "\n";
+	}
+}
+
+# Find the average torrent size for each episode
+my %avg = ();
+foreach my $episode (keys(%tors)) {
+	my $count = 0;
+	$avg{$episode} = 0.001;
+	foreach my $tor (@{ $tors{$episode} }) {
+		$avg{$episode} += $tor->{'size'};
+		$count++;
+	}
+	if ($count > 0) {
+		$avg{$episode} /= $count;
+	}
+	if ($DEBUG) {
+		print STDERR 'Episode ' . $episode . ' average size: ' . int($avg{$episode}) . " MiB\n";
+	}
+}
+
+# Calculate an adjusted count based on the original count and the relative file size
+foreach my $episode (keys(%tors)) {
+	foreach my $tor (@{ $tors{$episode} }) {
+		my $count = $tor->{'seeds'} + $tor->{'leaches'};
+		if ($tor->{'size'} >= $avg{$episode}) {
+			$tor->{'adj_count'} = int($count * ($SIZE_BONUS * ($tor->{'size'} / $avg{$episode})));
+		} else {
+			$tor->{'adj_count'} = int($count * ((1 / $SIZE_PENALTY) * ($tor->{'size'} / $avg{$episode})));
+		}
+		if ($DEBUG) {
+			print STDERR 'Possible URL (' . $tor->{'adj_count'} . ' size-adjusted sources): ' . $tor->{'url'} . "\n";
+		}
+	}
+}
+
+# Pick the best-adjusted-count torrent for each episode
+my %urls = ();
+foreach my $episode (keys(%tors)) {
+	my $max = undef();
+	foreach my $tor (@{ $tors{$episode} }) {
+		if (!defined($max) || $tor->{'adj_count'} > $max) {
+			$urls{$episode} = $tor->{'url'};
+			$max = $tor->{'adj_count'};
+			if ($DEBUG) {
+				print STDERR 'New semi-final URL (adjusted count: ' . $tor->{'adj_count'} . '): ' . $tor->{'url'} . "\n";
+			}
+		} elsif ($DEBUG) {
+			print STDERR 'Skipping for non-max adjusted count (' . $tor->{'adj_count'} . '): ' . $tor->{'url'} . "\n";
+		}
+	}
+}
+
+# Output
+foreach my $episode (keys(%tors)) {
+	if (defined($urls{$episode})) {
+		print $urls{$episode} . "\n";
+		if ($DEBUG) {
+			print STDERR 'Final URL: ' . $urls{$episode} . "\n";
+		}
+	} elsif ($DEBUG) {
+		print STDERR 'No URL found for URL: ' . $episode . "\n";
+	}
+}
+
+# Cleanup
+unlink($cookies);
+exit(0);

+ 49 - 0
folderFixup.sh

@@ -0,0 +1,49 @@
+#!/bin/bash
+
+# Parameters
+inFolder="/mnt/media/TV"
+
+# Command line
+if [ -n "${1}" ]; then
+	inFolder="${1}"
+fi
+if [ ! -e "${inFolder}" ]; then
+	echo "Usage: `basename "${0}"` input_folder" 1>&2
+	exit 1
+fi
+
+# Bail if we're already running
+me="`basename "${0}"`"
+if [ `ps auwx | grep -v grep | grep "${me}" | wc -l` -gt 2 ]; then
+	exit 0
+fi
+
+# Bail if the load average is high
+LOAD="`uptime | awk -F ': ' '{print $2}' | cut -d '.' -f 1`"
+CPU_COUNT="`sysctl -n hw.ncpu`"
+if [ $LOAD -gt $(( 2 * $CPU_COUNT )) ]; then
+	exit 0
+fi
+
+# Bail if WoW is running
+if ps auwx | grep -v grep | grep -q "World of Warcraft/World of Warcraft.app/Contents/MacOS/World of Warcraft"; then
+	exit 0
+fi
+
+# Bail if the media share isn't available
+if ! ~/bin/video/isMediaMounted; then
+	exit 0
+fi
+
+# Cache output
+tmp="`mktemp -t 'folderFixup.XXXXXXXX'`"
+
+# Re-wrap or recode as needed
+find "${inFolder}" -mindepth 1 -type d -exec ~/bin/video/folderToMov.sh {} \; 1>>"${tmp}" 2>&1
+
+# Filter the output
+cat "${tmp}" | \
+	grep -Ev "^cp: .*: could not copy extended attributes to .*: Operation not permitted$" | \
+	grep -v "GetFileInfo: could not get info about file (-1401)" | \
+	grep -v "ERROR: Unexpected Error. (-1401)  on file: " | \
+	grep -v "ERROR: Unexpected Error. (-5000)  on file: "

+ 47 - 0
folderToMov.sh

@@ -0,0 +1,47 @@
+#!/bin/bash
+
+# Command line
+inFolder="${1}"
+if [ -z "${inFolder}" ] || [ ! -e "${inFolder}" ]; then
+	echo "Usage: `basename "${0}"` input_folder" 1>&2
+	exit 1
+fi
+
+# Bail if we're already running a lot
+me="`basename "${0}"`"
+if [ `ps auwx | grep -v grep | grep "${me}" | wc -l` -gt 10 ]; then
+	exit 0
+fi
+
+# Bail if the load average is high
+LOAD="`uptime | awk -F ': ' '{print $2}' | cut -d '.' -f 1`"
+CPU_COUNT="`sysctl -n hw.ncpu`"
+if [ $LOAD -gt $(( 2 * $CPU_COUNT )) ]; then
+	exit 0
+fi
+
+# Prefer recoding to rewrapping if the file is overrate
+~/bin/video/findRecode0 "${inFolder}" | xargs -0 -n1 ~/bin/video/recode
+
+# Move to the input folder
+inFolder="`cd "${inFolder}" && pwd`"
+cd "${inFolder}"
+
+# Cycle through the folder looking for certain video files that should be converted to better containers
+for i in *.[aA][vV][iI] *.[wW][mM][vV] *.[mM][kK][vV] *.[dD][iI][vV][xX] *.[fF][lL][vV]; do
+	# Construct the full path
+	file="${inFolder}/${i}"
+
+	# Make sure the file is reasonable
+	if [ ! -e "${file}" ]; then
+		continue
+	fi
+
+	# Bail if WoW is running
+	if ps auwx | grep -v grep | grep -q "World of Warcraft/World of Warcraft.app/Contents/MacOS/World of Warcraft"; then
+		exit 0
+	fi
+
+	# Convert to M4V/MOV
+	~/bin/video/toMov.sh "${file}"
+done

+ 6 - 0
isMediaMounted

@@ -0,0 +1,6 @@
+#!/bin/bash
+
+if [ -d /mnt/media/TV ]; then
+	exit 0
+fi
+exit 1

+ 105 - 0
movInfo.pl

@@ -0,0 +1,105 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+
+# Prototypes
+sub run_mplayer(@);
+
+# Includes and paramters
+use IPC::Open3;
+use File::Basename;
+my @exec_args = ('mplayer', '-identify', '-vo', 'null', '-ao', 'null', '-frames', '1');
+my $MAX_AUDIO = 10;
+
+# Command line parameters
+my ($infile, $MODE) = @ARGV;
+if (!-r $infile) {
+	die('Usage: ' . basename($0) . "input_file [mode]\n");
+}
+if ($MODE) {
+	$MODE = uc($MODE);
+}
+
+# Run mplayer
+my @args = @exec_args;
+push(@args, $infile);
+my $data = run_mplayer(@args);
+
+# Sanity check
+if (!defined($data->{'VIDEO_FORMAT'}) || length($data->{'VIDEO_FORMAT'}) < 1) {
+	die(basename($0) . ': Unable to find any video streams in: ' . $infile . "\n");
+}
+
+# Duplicate the audio data for track 0 with the appropriate index
+foreach my $key (keys(%{$data})) {
+	if ($key =~ /AUDIO/) {
+		$data->{ $key . '_0' } = $data->{$key};
+	}
+}
+
+# Run mplayer again for each audio track
+if (!defined($data->{'AUDIO_ID'})) {
+	$data->{'AUDIO_ID'} = -1;
+}
+for (my $i = 0 ; $i <= $data->{'AUDIO_ID'} && $i < $MAX_AUDIO ; $i++) {
+	my @args = @exec_args;
+	push(@args, '-aid', $i, $infile);
+	my $audio = run_mplayer(@args);
+
+	foreach my $key (keys(%{$audio})) {
+		if ($key =~ /AUDIO/) {
+			$data->{ $key . '_' . $i } = $audio->{$key};
+		}
+	}
+}
+
+# Output
+if (defined($MODE) && length($MODE) > 0) {
+	if (defined($data->{$MODE})) {
+		print $data->{$MODE} . "\n";
+	} else {
+		die(basename($0) . ': No such data: ' . $MODE . "\n");
+	}
+} else {
+	foreach my $key (keys(%{$data})) {
+		print $key . '=' . $data->{$key} . "\n";
+	}
+}
+
+# Cleanup
+exit(0);
+
+# Run mplayer and return a hash of all lines that start with ID_
+# Timeout after 30 seconds or 100 non-matching lines
+sub run_mplayer(@) {
+	my %data = ();
+	eval {
+		my $child_in  = '';
+		my $child_out = '';
+		my $pid       = open3($child_in, $child_out, $child_out, @_);
+
+		# Timeout after 30 seconds
+		local $SIG{ALRM} = sub { kill(9, $pid); die("Timeout\n"); };
+		alarm(30);
+
+		# Read the output
+		my $errcnt = 0;
+		while (<$child_out>) {
+			if (/^ID_(.+)\=(.*)/) {
+				$data{$1} = $2;
+			} else {
+				$errcnt++;
+			}
+			if ($errcnt > 100) {
+				kill(9, $pid);
+				die("Too many errors\n");
+			}
+		}
+
+		# Cleanup and cancel the alarm
+		waitpid($pid, 0);
+		close($child_out);
+		alarm(0);
+	};
+	return \%data;
+}

+ 53 - 0
plex

@@ -0,0 +1,53 @@
+#!/bin/bash
+
+# Parameters
+USER="plex"
+PASS="bobola22"
+HOST="localhost"
+PORT="3000"
+
+# Figure out what we should do
+CMD="`basename "${0}" | sed 's%^plex\-%%' | tr a-z A-Z`"
+
+# Reduce typing
+CURL="curl --silent --get --user ${USER}:${PASS}"
+RPATH="/xbmcCmds/xbmcHttp"
+URL="http://${HOST}:${PORT}${RPATH}"
+OUTPUT=0
+
+# Pick a command
+if [ "${CMD}" == "UPDATE" ]; then
+	URL="${URL}/?command=ExecBuiltIn&parameter=XBMC.updatelibrary(video)"
+elif [ "${CMD}" == "CLEAN" ]; then
+	URL="${URL}?command=ExecBuiltIn&parameter=XBMC.CleanLibrary(video)"
+elif [ "${CMD}" == "FILES" ]; then
+	URL="${URL}?command=queryvideodatabase(SELECT%20path.strPath,%20files.strFilename%20FROM%20path%20JOIN%20files%20ON%20path.idPath%20=%20files.idPath%20JOIN%20episode%20ON%20files.idFile%20=%20episode.idFile)"
+	OUTPUT=1
+elif [ "${CMD}" == "BUTTON" ]; then
+	URL="${URL}?command=SendKey(${1})"
+elif [ "${CMD}" == "PLAYING" ]; then
+	URL="${URL}?command=GetCurrentlyPlaying"
+	OUTPUT=1
+elif [ "${CMD}" == "GUI" ]; then
+	URL="${URL}?command=GetGuiDescription"
+	OUTPUT=1
+else
+	echo "Unknown command: ${CMD}" 1>&2
+	exit 1
+fi
+
+# Bail if Plex is not running
+if ! ps awx -o comm= | grep -v 'Media Server' | grep -q 'MacOS/Plex'; then
+	if [ $OUTPUT -gt 0 ]; then
+		echo "Plex is not running" 1>&2
+		exit 1
+	fi
+	exit 0
+fi
+
+# Run the command
+if [ $OUTPUT -gt 0 ]; then
+	$CURL "${URL}"
+else
+	$CURL "${URL}" >/dev/null
+fi

+ 53 - 0
plex-button

@@ -0,0 +1,53 @@
+#!/bin/bash
+
+# Parameters
+USER="plex"
+PASS="bobola22"
+HOST="localhost"
+PORT="3000"
+
+# Figure out what we should do
+CMD="`basename "${0}" | sed 's%^plex\-%%' | tr a-z A-Z`"
+
+# Reduce typing
+CURL="curl --silent --get --user ${USER}:${PASS}"
+RPATH="/xbmcCmds/xbmcHttp"
+URL="http://${HOST}:${PORT}${RPATH}"
+OUTPUT=0
+
+# Pick a command
+if [ "${CMD}" == "UPDATE" ]; then
+	URL="${URL}/?command=ExecBuiltIn&parameter=XBMC.updatelibrary(video)"
+elif [ "${CMD}" == "CLEAN" ]; then
+	URL="${URL}?command=ExecBuiltIn&parameter=XBMC.CleanLibrary(video)"
+elif [ "${CMD}" == "FILES" ]; then
+	URL="${URL}?command=queryvideodatabase(SELECT%20path.strPath,%20files.strFilename%20FROM%20path%20JOIN%20files%20ON%20path.idPath%20=%20files.idPath%20JOIN%20episode%20ON%20files.idFile%20=%20episode.idFile)"
+	OUTPUT=1
+elif [ "${CMD}" == "BUTTON" ]; then
+	URL="${URL}?command=SendKey(${1})"
+elif [ "${CMD}" == "PLAYING" ]; then
+	URL="${URL}?command=GetCurrentlyPlaying"
+	OUTPUT=1
+elif [ "${CMD}" == "GUI" ]; then
+	URL="${URL}?command=GetGuiDescription"
+	OUTPUT=1
+else
+	echo "Unknown command: ${CMD}" 1>&2
+	exit 1
+fi
+
+# Bail if Plex is not running
+if ! ps awx -o comm= | grep -v 'Media Server' | grep -q 'MacOS/Plex'; then
+	if [ $OUTPUT -gt 0 ]; then
+		echo "Plex is not running" 1>&2
+		exit 1
+	fi
+	exit 0
+fi
+
+# Run the command
+if [ $OUTPUT -gt 0 ]; then
+	$CURL "${URL}"
+else
+	$CURL "${URL}" >/dev/null
+fi

+ 23 - 0
plex-checkTV

@@ -0,0 +1,23 @@
+#!/bin/bash
+
+# Path must be the default /Volumes and not /mnt -- Plex stores the former in its DB
+TV_PATH="/Volumes/media/TV"
+
+# Get the list of TV files in the library
+LIB_TEMP="`mktemp -t 'checkTV.XXXXXXXX'`"
+~/bin/video/plex-files | ~/bin/video/plex-filter | grep "^${TV_PATH}/" | sort -f > "${LIB_TEMP}"
+
+# Bail if we did not find anything
+if [ `cat "${LIB_TEMP}" | wc -l` -lt 1 ]; then
+	exit 1
+fi
+
+# Get the list of all files on the disk
+DISK_TEMP="`mktemp -t 'checkTV.XXXXXXXX'`"
+find "${TV_PATH}" -type f -name '*.m[op4k][4v]' | grep -v '\/\._' | sort -f > "${DISK_TEMP}"
+
+# Compare
+diff -i -U 0 "${DISK_TEMP}" "${LIB_TEMP}" | grep -v '^---' | grep -v '^+++' | grep -v '@@' | grep -v '^+'
+
+# Cleanup
+rm -rf "${LIB_TEMP}" "${DISK_TEMP}"

+ 53 - 0
plex-clean

@@ -0,0 +1,53 @@
+#!/bin/bash
+
+# Parameters
+USER="plex"
+PASS="bobola22"
+HOST="localhost"
+PORT="3000"
+
+# Figure out what we should do
+CMD="`basename "${0}" | sed 's%^plex\-%%' | tr a-z A-Z`"
+
+# Reduce typing
+CURL="curl --silent --get --user ${USER}:${PASS}"
+RPATH="/xbmcCmds/xbmcHttp"
+URL="http://${HOST}:${PORT}${RPATH}"
+OUTPUT=0
+
+# Pick a command
+if [ "${CMD}" == "UPDATE" ]; then
+	URL="${URL}/?command=ExecBuiltIn&parameter=XBMC.updatelibrary(video)"
+elif [ "${CMD}" == "CLEAN" ]; then
+	URL="${URL}?command=ExecBuiltIn&parameter=XBMC.CleanLibrary(video)"
+elif [ "${CMD}" == "FILES" ]; then
+	URL="${URL}?command=queryvideodatabase(SELECT%20path.strPath,%20files.strFilename%20FROM%20path%20JOIN%20files%20ON%20path.idPath%20=%20files.idPath%20JOIN%20episode%20ON%20files.idFile%20=%20episode.idFile)"
+	OUTPUT=1
+elif [ "${CMD}" == "BUTTON" ]; then
+	URL="${URL}?command=SendKey(${1})"
+elif [ "${CMD}" == "PLAYING" ]; then
+	URL="${URL}?command=GetCurrentlyPlaying"
+	OUTPUT=1
+elif [ "${CMD}" == "GUI" ]; then
+	URL="${URL}?command=GetGuiDescription"
+	OUTPUT=1
+else
+	echo "Unknown command: ${CMD}" 1>&2
+	exit 1
+fi
+
+# Bail if Plex is not running
+if ! ps awx -o comm= | grep -v 'Media Server' | grep -q 'MacOS/Plex'; then
+	if [ $OUTPUT -gt 0 ]; then
+		echo "Plex is not running" 1>&2
+		exit 1
+	fi
+	exit 0
+fi
+
+# Run the command
+if [ $OUTPUT -gt 0 ]; then
+	$CURL "${URL}"
+else
+	$CURL "${URL}" >/dev/null
+fi

+ 53 - 0
plex-files

@@ -0,0 +1,53 @@
+#!/bin/bash
+
+# Parameters
+USER="plex"
+PASS="bobola22"
+HOST="localhost"
+PORT="3000"
+
+# Figure out what we should do
+CMD="`basename "${0}" | sed 's%^plex\-%%' | tr a-z A-Z`"
+
+# Reduce typing
+CURL="curl --silent --get --user ${USER}:${PASS}"
+RPATH="/xbmcCmds/xbmcHttp"
+URL="http://${HOST}:${PORT}${RPATH}"
+OUTPUT=0
+
+# Pick a command
+if [ "${CMD}" == "UPDATE" ]; then
+	URL="${URL}/?command=ExecBuiltIn&parameter=XBMC.updatelibrary(video)"
+elif [ "${CMD}" == "CLEAN" ]; then
+	URL="${URL}?command=ExecBuiltIn&parameter=XBMC.CleanLibrary(video)"
+elif [ "${CMD}" == "FILES" ]; then
+	URL="${URL}?command=queryvideodatabase(SELECT%20path.strPath,%20files.strFilename%20FROM%20path%20JOIN%20files%20ON%20path.idPath%20=%20files.idPath%20JOIN%20episode%20ON%20files.idFile%20=%20episode.idFile)"
+	OUTPUT=1
+elif [ "${CMD}" == "BUTTON" ]; then
+	URL="${URL}?command=SendKey(${1})"
+elif [ "${CMD}" == "PLAYING" ]; then
+	URL="${URL}?command=GetCurrentlyPlaying"
+	OUTPUT=1
+elif [ "${CMD}" == "GUI" ]; then
+	URL="${URL}?command=GetGuiDescription"
+	OUTPUT=1
+else
+	echo "Unknown command: ${CMD}" 1>&2
+	exit 1
+fi
+
+# Bail if Plex is not running
+if ! ps awx -o comm= | grep -v 'Media Server' | grep -q 'MacOS/Plex'; then
+	if [ $OUTPUT -gt 0 ]; then
+		echo "Plex is not running" 1>&2
+		exit 1
+	fi
+	exit 0
+fi
+
+# Run the command
+if [ $OUTPUT -gt 0 ]; then
+	$CURL "${URL}"
+else
+	$CURL "${URL}" >/dev/null
+fi

+ 18 - 0
plex-filter

@@ -0,0 +1,18 @@
+#!/usr/bin/perl -w
+use strict;
+
+my $prefix = '';
+while (<STDIN>) {
+	foreach my $field (split(/\>/, $_)) {
+		$field =~ s/\>/\>\n/g;
+		$field =~ s/\<\/?\w+$//g;
+		if ($field =~ /^\s*$/) {
+			next;
+		}
+		if ($field =~ /^\//) {
+			$prefix = $field;
+			next;
+		}
+		print $prefix . $field . "\n";
+	}
+}

+ 53 - 0
plex-gui

@@ -0,0 +1,53 @@
+#!/bin/bash
+
+# Parameters
+USER="plex"
+PASS="bobola22"
+HOST="localhost"
+PORT="3000"
+
+# Figure out what we should do
+CMD="`basename "${0}" | sed 's%^plex\-%%' | tr a-z A-Z`"
+
+# Reduce typing
+CURL="curl --silent --get --user ${USER}:${PASS}"
+RPATH="/xbmcCmds/xbmcHttp"
+URL="http://${HOST}:${PORT}${RPATH}"
+OUTPUT=0
+
+# Pick a command
+if [ "${CMD}" == "UPDATE" ]; then
+	URL="${URL}/?command=ExecBuiltIn&parameter=XBMC.updatelibrary(video)"
+elif [ "${CMD}" == "CLEAN" ]; then
+	URL="${URL}?command=ExecBuiltIn&parameter=XBMC.CleanLibrary(video)"
+elif [ "${CMD}" == "FILES" ]; then
+	URL="${URL}?command=queryvideodatabase(SELECT%20path.strPath,%20files.strFilename%20FROM%20path%20JOIN%20files%20ON%20path.idPath%20=%20files.idPath%20JOIN%20episode%20ON%20files.idFile%20=%20episode.idFile)"
+	OUTPUT=1
+elif [ "${CMD}" == "BUTTON" ]; then
+	URL="${URL}?command=SendKey(${1})"
+elif [ "${CMD}" == "PLAYING" ]; then
+	URL="${URL}?command=GetCurrentlyPlaying"
+	OUTPUT=1
+elif [ "${CMD}" == "GUI" ]; then
+	URL="${URL}?command=GetGuiDescription"
+	OUTPUT=1
+else
+	echo "Unknown command: ${CMD}" 1>&2
+	exit 1
+fi
+
+# Bail if Plex is not running
+if ! ps awx -o comm= | grep -v 'Media Server' | grep -q 'MacOS/Plex'; then
+	if [ $OUTPUT -gt 0 ]; then
+		echo "Plex is not running" 1>&2
+		exit 1
+	fi
+	exit 0
+fi
+
+# Run the command
+if [ $OUTPUT -gt 0 ]; then
+	$CURL "${URL}"
+else
+	$CURL "${URL}" >/dev/null
+fi

+ 53 - 0
plex-playing

@@ -0,0 +1,53 @@
+#!/bin/bash
+
+# Parameters
+USER="plex"
+PASS="bobola22"
+HOST="localhost"
+PORT="3000"
+
+# Figure out what we should do
+CMD="`basename "${0}" | sed 's%^plex\-%%' | tr a-z A-Z`"
+
+# Reduce typing
+CURL="curl --silent --get --user ${USER}:${PASS}"
+RPATH="/xbmcCmds/xbmcHttp"
+URL="http://${HOST}:${PORT}${RPATH}"
+OUTPUT=0
+
+# Pick a command
+if [ "${CMD}" == "UPDATE" ]; then
+	URL="${URL}/?command=ExecBuiltIn&parameter=XBMC.updatelibrary(video)"
+elif [ "${CMD}" == "CLEAN" ]; then
+	URL="${URL}?command=ExecBuiltIn&parameter=XBMC.CleanLibrary(video)"
+elif [ "${CMD}" == "FILES" ]; then
+	URL="${URL}?command=queryvideodatabase(SELECT%20path.strPath,%20files.strFilename%20FROM%20path%20JOIN%20files%20ON%20path.idPath%20=%20files.idPath%20JOIN%20episode%20ON%20files.idFile%20=%20episode.idFile)"
+	OUTPUT=1
+elif [ "${CMD}" == "BUTTON" ]; then
+	URL="${URL}?command=SendKey(${1})"
+elif [ "${CMD}" == "PLAYING" ]; then
+	URL="${URL}?command=GetCurrentlyPlaying"
+	OUTPUT=1
+elif [ "${CMD}" == "GUI" ]; then
+	URL="${URL}?command=GetGuiDescription"
+	OUTPUT=1
+else
+	echo "Unknown command: ${CMD}" 1>&2
+	exit 1
+fi
+
+# Bail if Plex is not running
+if ! ps awx -o comm= | grep -v 'Media Server' | grep -q 'MacOS/Plex'; then
+	if [ $OUTPUT -gt 0 ]; then
+		echo "Plex is not running" 1>&2
+		exit 1
+	fi
+	exit 0
+fi
+
+# Run the command
+if [ $OUTPUT -gt 0 ]; then
+	$CURL "${URL}"
+else
+	$CURL "${URL}" >/dev/null
+fi

+ 53 - 0
plex-update

@@ -0,0 +1,53 @@
+#!/bin/bash
+
+# Parameters
+USER="plex"
+PASS="bobola22"
+HOST="localhost"
+PORT="3000"
+
+# Figure out what we should do
+CMD="`basename "${0}" | sed 's%^plex\-%%' | tr a-z A-Z`"
+
+# Reduce typing
+CURL="curl --silent --get --user ${USER}:${PASS}"
+RPATH="/xbmcCmds/xbmcHttp"
+URL="http://${HOST}:${PORT}${RPATH}"
+OUTPUT=0
+
+# Pick a command
+if [ "${CMD}" == "UPDATE" ]; then
+	URL="${URL}/?command=ExecBuiltIn&parameter=XBMC.updatelibrary(video)"
+elif [ "${CMD}" == "CLEAN" ]; then
+	URL="${URL}?command=ExecBuiltIn&parameter=XBMC.CleanLibrary(video)"
+elif [ "${CMD}" == "FILES" ]; then
+	URL="${URL}?command=queryvideodatabase(SELECT%20path.strPath,%20files.strFilename%20FROM%20path%20JOIN%20files%20ON%20path.idPath%20=%20files.idPath%20JOIN%20episode%20ON%20files.idFile%20=%20episode.idFile)"
+	OUTPUT=1
+elif [ "${CMD}" == "BUTTON" ]; then
+	URL="${URL}?command=SendKey(${1})"
+elif [ "${CMD}" == "PLAYING" ]; then
+	URL="${URL}?command=GetCurrentlyPlaying"
+	OUTPUT=1
+elif [ "${CMD}" == "GUI" ]; then
+	URL="${URL}?command=GetGuiDescription"
+	OUTPUT=1
+else
+	echo "Unknown command: ${CMD}" 1>&2
+	exit 1
+fi
+
+# Bail if Plex is not running
+if ! ps awx -o comm= | grep -v 'Media Server' | grep -q 'MacOS/Plex'; then
+	if [ $OUTPUT -gt 0 ]; then
+		echo "Plex is not running" 1>&2
+		exit 1
+	fi
+	exit 0
+fi
+
+# Run the command
+if [ $OUTPUT -gt 0 ]; then
+	$CURL "${URL}"
+else
+	$CURL "${URL}" >/dev/null
+fi

+ 86 - 0
recode

@@ -0,0 +1,86 @@
+#!/bin/bash
+set -e
+
+# Parameters
+# Allow environmental overrides
+if [ -z "${RECODE_MIN_RATIO}" ]; then
+	RECODE_MIN_RATIO=40
+fi
+if [ -z "${RECODE_MAX_DIFF}" ]; then
+	RECODE_MAX_DIFF=8
+fi
+if [ -z "${KEEP_IN}" ]; then
+	KEEP_IN=0
+fi
+
+# Command-line parameters
+IN="${1}"
+OUT="${2}"
+
+# Sanity check
+if [ ! -r "${IN}" ]; then
+	echo "Usage: `basename "${0}"` in_file [out_file]" 1>&2
+	exit 1
+fi
+if [ -e "${OUT}" ]; then
+	echo "`basename "${0}"`: Output file exists: ${OUT}" 1>&2
+	exit 1
+fi
+
+# Recode
+export RECODE_OUTFILE=1
+OUT="`~/bin/video/encode.pl "${IN}" "${OUT}"`"
+
+# Remove the original if all went well
+# Some sanity checking here, but mostly relying on the -e flag
+if [ -r "${IN}" ]; then
+
+	# Ensure the output file exists
+	if [ -z "${OUT}" ] || [ ! -r "${OUT}" ]; then
+		echo "`basename "${0}"`: Output file not defined or does not exist. Aborting..." 1>&2
+		exit 1
+	fi
+
+	# Check to be sure the output video has a reasonable file size
+	SIZE_RATIO=0
+	SIZE_IN="`stat -f %z "${IN}" 2>/dev/null`"
+	SIZE_OUT="`stat -f %z "${OUT}" 2>/dev/null`"
+	if [ -n "${SIZE_IN}" ] && [ -n "${SIZE_OUT}" ]; then
+		SIZE_RATIO=$(( $SIZE_OUT * 100 / $SIZE_IN ))
+	fi
+	if [ $SIZE_RATIO -lt $RECODE_MIN_RATIO ]; then
+		echo "`basename "${0}"`: Output file too small: ${OUT}. Aborting..." 1>&2
+		rm -f "${OUT}"
+		exit 1
+	fi
+
+	# Check to be sure the videos are (about) the same duration
+	LENGTH_DIFF=$(( $RECODE_MAX_DIFF + 1 ))
+	LENGTH_IN="`~/bin/video/movInfo.pl "${IN}" LENGTH | sed 's%\..*$%%'`"
+	LENGTH_OUT="`~/bin/video/movInfo.pl "${OUT}" LENGTH | sed 's%\..*$%%'`"
+	if [ -z "${LENGTH_IN}" ] || [ -z "${LENGTH_OUT}" ]; then
+		echo "`basename "${0}"`: Unable to determine length_in or length_out. Leaving all files in place." 1>&2
+		exit 1
+	fi
+	if [ -n "${LENGTH_OUT}" ] && [ -n "${LENGTH_IN}" ]; then
+		LENGTH_DIFF=$(( 100 -  ( $LENGTH_OUT * 100 / $LENGTH_IN ) ))
+		if [ $LENGTH_DIFF -lt 0 ]; then
+			LENGTH_DIFF=$(( $LENGTH_DIFF * -1 ))
+		fi
+	fi
+	if [ $LENGTH_DIFF -gt $RECODE_MAX_DIFF ] ; then
+		KEEP_IN=1
+		if [ $LENGTH_IN -le 1 ] && [ $LENGTH_OUT -ge 1 ]; then
+			echo "`basename "${0}"`: Input file has an invalid duration and will not be deleted: ${IN}" 1>&2
+		else
+			echo "`basename "${0}"`: Output file duration (${LENGTH_OUT}) does not match input file duration (${LENGTH_IN}): ${IN}" 1>&2
+			rm -f "${OUT}"
+			exit 1
+		fi
+	fi
+
+	# Remove the input file if all is well
+	if [ $KEEP_IN -eq 0 ]; then
+		rm -f "${IN}"
+	fi
+fi

+ 152 - 0
renameFromDisk.pl

@@ -0,0 +1,152 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+# Includes
+use URI::Escape;
+use XML::Simple;
+use LWP::Simple;
+use File::Basename;
+use HTTP::Request::Common;
+
+# Parameters
+my ($host, $user, $pass, $section) = @ARGV;
+if (!$host || !$user || !$pass || !$section) {
+	die('Usage: ' . basename($0) . ' host[:port] user passwd section_number');
+}
+
+# Globals
+my $baseURL = 'http://' . $host;
+my $auth    = 'X-Plex-User=' . $user . '&X-Plex-Pass=' . $pass;
+my $xml     = new XML::Simple;
+
+# Fetch the list of all shows in the section
+my @shows = ();
+print STDERR "Fetching shows...\n";
+{
+	my $content = get($baseURL . '/library/sections/' . $section . '/all/?' . $auth);
+	if (!$content) {
+		die(basename($0) . ': Unable to fetch data for section: ' . $section . "\n");
+	}
+
+	# Find all TV shows in the section
+	{
+		my $tree = $xml->XMLin($content);
+		@shows = keys(%{ $tree->{'Directory'} });
+	}
+}
+print STDERR 'Found ' . scalar(@shows) . " shows\n";
+
+# Fetch the list of all seasons in each show
+my @seasons = ();
+print STDERR "Fetching seasons...\n";
+foreach my $show (@shows) {
+	my $content = get($baseURL . $show . '?' . $auth);
+	if (!$content) {
+		warn(basename($0) . ': Could not fetch seasons for show: ' . $show . "\n");
+		next;
+	}
+
+	# Find all seasons in the show
+	{
+		my $tree = $xml->XMLin($content);
+
+		# XML::Simple retuns different structure depending on the number of same-named child elements
+		if ($tree->{'Directory'}->{'key'}) {
+			push(@seasons, $tree->{'Directory'}->{'key'});
+		} else {
+			push(@seasons, keys(%{ $tree->{'Directory'} }));
+		}
+	}
+}
+print STDERR 'Found ' . scalar(@seasons) . " seasons\n";
+
+# Forget the list of shows
+undef(@shows);
+
+# Fetch the list of all episodes in each season
+my @episodes = ();
+print STDERR "Fetching episodes...\n";
+foreach my $season (@seasons) {
+	my $content = get($baseURL . $season . '?' . $auth);
+	if (!$content) {
+		warn(basename($0) . ': Could not fetch episodes for season: ' . $season . "\n");
+		next;
+	}
+
+	# Find all episodes in the season
+	{
+		my $tree = $xml->XMLin($content);
+
+		# XML::Simple retuns different structure depending on the number of same-named child elements
+		if ($tree->{'Video'}->{'key'}) {
+			push(@episodes, $tree->{'Video'}->{'key'});
+		} else {
+			push(@episodes, keys(%{ $tree->{'Video'} }));
+		}
+	}
+}
+print STDERR 'Found ' . scalar(@episodes) . " episodes\n";
+
+# Forget the list of seasons
+undef(@seasons);
+
+# Fetch the metadata from each episode
+print STDERR "Checking episodes...\n";
+foreach my $episode (@episodes) {
+	my $content = get($baseURL . $episode . '?' . $auth);
+	if (!$content) {
+		warn(basename($0) . ': Could not fetch metadata for episode: ' . $episode . "\n");
+		next;
+	}
+
+	# Parse the metadata
+	{
+		my $tree    = $xml->XMLin($content);
+		my $title   = $tree->{'Video'}->{'title'};
+		my $summary = $tree->{'Video'}->{'summary'};
+		my $file    = $tree->{'Video'}->{'Media'}->{'Part'}->{'file'};
+
+		if (!$title) {
+			warn('Invalid title for episode: ' . $episode . "\n");
+			next;
+		}
+		if (!$file) {
+			if (!$tree->{'Video'}->{'Media'}->{'key'}) {
+				warn('No support for multi-file episodes: ' . $episode . "\n");
+			} else {
+				warn('Invalid file for episode: ' . $episode . "\n");
+			}
+			next;
+		}
+
+		# Rename if the file has a valid name but the episode does not
+		$file = uri_unescape($file);
+		$file = basename($file);
+		$file =~ s/^\d+\s+\-\s*//;
+		$file =~ s/S\d+E\d+\s+\-\s*//i;
+		$file =~ s/\.\w{2,3}//;
+		if (!$file || length($file) < 2 || $file =~ /NoName/i || $file =~ /Episode\s+\d+/i) {
+			next;
+		}
+		if ($file =~ /\s+S\d+D\d+\-\d+$/) {
+			print STDERR 'Skipping encoder-named file: ' . $file . "\n";
+		}
+		if ($title =~ /^Episode\s+\d+$/) {
+			my $ua      = LWP::UserAgent->new();
+			my $encoded = uri_escape($file);
+
+			print STDERR 'Renaming ' . $episode . ' from "' . $title . '" to "' . $file . '"' . "\n";
+			my $url = $baseURL . $episode . '?title=' . $encoded . '&titleSort=' . $encoded . '&title.locked=1&titleSort.locked=1&' . $auth;
+			my $request = HTTP::Request->new('PUT' => $url);
+			$ua->request($request);
+
+			if (!$summary) {
+				print STDERR "\tAlso setting summary\n";
+				$url = $baseURL . $episode . '?title=' . $encoded . '&summary=' . $encoded . '&summary.locked=1&' . $auth;
+				$request = HTTP::Request->new('PUT' => $url);
+				$ua->request($request);
+			}
+		}
+	}
+}

+ 91 - 0
timeout

@@ -0,0 +1,91 @@
+#!/bin/bash
+#
+# The Bash shell script executes a command with a time-out.
+# Upon time-out expiration SIGTERM (15) is sent to the process. If the signal
+# is blocked, then the subsequent SIGKILL (9) terminates it.
+#
+# Based on the Bash documentation example.
+
+# Hello Chet,
+# please find attached a "little easier"  :-)  to comprehend
+# time-out example.  If you find it suitable, feel free to include
+# anywhere: the very same logic as in the original examples/scripts, a
+# little more transparent implementation to my taste.
+#
+# Dmitry V Golovashkin <Dmitry.Golovashkin@sas.com>
+
+scriptName="${0##*/}"
+
+declare -i DEFAULT_TIMEOUT=9
+declare -i DEFAULT_INTERVAL=1
+declare -i DEFAULT_DELAY=1
+
+# Timeout.
+declare -i timeout=DEFAULT_TIMEOUT
+# Interval between checks if the process is still alive.
+declare -i interval=DEFAULT_INTERVAL
+# Delay between posting the SIGTERM signal and destroying the process by SIGKILL.
+declare -i delay=DEFAULT_DELAY
+
+function printUsage() {
+    cat <<EOF
+
+Synopsis
+    $scriptName [-t timeout] [-i interval] [-d delay] command
+    Execute a command with a time-out.
+    Upon time-out expiration SIGTERM (15) is sent to the process. If SIGTERM
+    signal is blocked, then the subsequent SIGKILL (9) terminates it.
+
+    -t timeout
+        Number of seconds to wait for command completion.
+        Default value: $DEFAULT_TIMEOUT seconds.
+
+    -i interval
+        Interval between checks if the process is still alive.
+        Positive integer, default value: $DEFAULT_INTERVAL seconds.
+
+    -d delay
+        Delay between posting the SIGTERM signal and destroying the
+        process by SIGKILL. Default value: $DEFAULT_DELAY seconds.
+
+As of today, Bash does not support floating point arithmetic (sleep does),
+therefore all delay/time values must be integers.
+EOF
+}
+
+# Options.
+while getopts ":t:i:d:" option; do
+    case "$option" in
+        t) timeout=$OPTARG ;;
+        i) interval=$OPTARG ;;
+        d) delay=$OPTARG ;;
+        *) printUsage; exit 1 ;;
+    esac
+done
+shift $((OPTIND - 1))
+
+# $# should be at least 1 (the command to execute), however it may be strictly
+# greater than 1 if the command itself has options.
+if (($# == 0 || interval <= 0)); then
+    printUsage
+    exit 1
+fi
+
+# kill -0 pid   Exit code indicates if a signal may be sent to $pid process.
+(
+    ((t = timeout))
+
+    while ((t > 0)); do
+        sleep $interval
+        kill -0 $$ || exit 0
+        ((t -= interval))
+    done
+
+    # Be nice, post SIGTERM first.
+    # The 'exit 0' below will be executed if any preceeding command fails.
+    kill -s SIGTERM $$ && kill -0 $$ || exit 0
+    sleep $delay
+    kill -s SIGKILL $$
+) 2> /dev/null &
+
+exec "$@"

+ 50 - 0
toMov.sh

@@ -0,0 +1,50 @@
+#!/bin/bash
+
+# Command line
+inFile="${1}"
+outFile="${2}"
+movLength="${3}"
+if [ -z "${inFile}" ] || [ ! -e "${inFile}" ]; then
+	echo "Usage: `basename "${0}"` input_file [output_file] [mov_length]" 1>&2
+	exit 1
+fi
+
+# Exclude files with DTS soundtracks
+ACODECS="`~/bin/video/movInfo.pl "${inFile}" | grep AUDIO_CODEC`"
+if [ -z "${ACODECS}" ]; then
+	echo "`basename "${0}"`: Could not determine audio codec" 1>&2
+	exit 2
+fi
+if echo "${ACODECS}" | grep -q ffdca; then
+	exit 1
+fi
+
+# Construct the output file name
+if [ -z "${outFile}" ]; then
+	# Use the input file name with a .mov extension
+	outFile="`basename "${inFile}"`"
+	outFile="`echo "${outFile}" | sed 's%\.[A-Za-z0-9]*$%.mov%'`"
+	outFile="`dirname "${inFile}"`/${outFile}"
+fi
+
+# Convert to MOV
+tmpFile="`mktemp -t toMov`"
+catmovie -q -self-contained -o "${tmpFile}" $chaps "${inFile}" 2>/dev/null
+
+# Check for errors
+if [ ! -e "${tmpFile}" ] || [ `stat -f '%z' "${tmpFile}"` -lt 1000 ]; then
+	echo "`basename "${0}"`: Error creating output file for input: ${inFile}" 1>&2
+
+	# Try to recode (with Handbrake/ffmpeg) if QuickTime fails
+	echo "`basename "${0}"`: Attempting recode instead..." 1>&2
+	~/bin/video/recode "${inFile}"
+	exit "${?}"
+fi
+
+# Move into place, dropping the original
+tmpOut="`mktemp "${outFile}.XXXXXXXX"`"
+cp -X "${tmpFile}" "${tmpOut}" && rm "${inFile}" && mv "${tmpOut}" "${outFile}"
+rm -f "${tmpFile}"
+
+# Exit cleanly
+exit 0

+ 139 - 0
torrentMonitored.pl

@@ -0,0 +1,139 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+# Includes
+use File::Basename;
+
+# Parameters
+my @BASE_DIRS = ('/mnt/media/TV', '/mnt/media/Download');
+my @skipped   = ();
+my @monitored = ();
+my @done      = ();
+
+# Command-line parameters
+my ($prg) = @ARGV;
+
+# For each directory
+for my $BASE_DIR (@BASE_DIRS) {
+
+	# For each show
+	opendir(BASE_DIR, $BASE_DIR)
+	  or die("Unable to open input directory: ${!}\n");
+	while (my $dir = readdir(BASE_DIR)) {
+
+		# Skip the stock entries
+		if (!$dir || $dir eq '.' || $dir eq '..') {
+			next;
+		}
+
+		# Construct the path
+		my $path = $BASE_DIR . '/' . $dir;
+
+		# Skip non-folders
+		if (!-d $path) {
+			next;
+		}
+
+		# Look for "skip" files
+		if (-e $path . '/skip') {
+			push(@skipped, $dir);
+			next;
+		}
+
+		# Look for season folders
+		opendir(SUB_DIR, $path)
+		  or die('Unable to open subfolder "' . $path . '": ' . $! . "\n");
+		while (my $subdir = readdir(SUB_DIR)) {
+
+			# Skip the stock entries
+			if (!$dir || $dir eq '.' || $dir eq '..') {
+				next;
+			}
+
+			# Construct the path
+			my $subpath = $path . '/' . $subdir;
+
+			# Skip non-folders
+			if (!-d $subpath) {
+				next;
+			}
+
+			# Skip symlinks
+			if (-l $subpath) {
+				next;
+			}
+
+			# Look for "season" folders
+			if ($subdir =~ /^Season\s+(\d+)$/i) {
+				my $season_num = $1;
+
+				# Construct a name
+				my $name = '';
+				if ($prg) {
+					$name = $subpath;
+				} else {
+					$name = $dir . ' - ' . $subdir;
+				}
+
+				# Check if the season is done
+				if (-e $subpath . '/season_done') {
+					push(@done, $name);
+
+					# Season 0 is always done
+				} elsif ($season_num == 0) {
+					push(@done, $name);
+
+					# Otherwise record the season
+				} else {
+					push(@monitored, $name);
+				}
+			}
+		}
+		close(SUB_DIR);
+	}
+	close(BASE_DIR);
+}
+
+# Print results
+if ($prg =~ /NULL/i) {
+	foreach my $show (@monitored) {
+		print $show . "\0";
+	}
+} elsif ($prg =~ /STORE/i) {
+	my %shows = ();
+	foreach my $show (@monitored) {
+		$shows{ basename(dirname($show)) } = 1;
+	}
+	foreach my $key (keys(%shows)) {
+		print $key . "\n";
+	}
+} else {
+	if (scalar(@done) > 0) {
+		print "\nDone:\n";
+		foreach my $show (@done) {
+			print $show . "\n";
+		}
+	} else {
+		print "\nNo seasons done\n";
+	}
+	if (scalar(@skipped) > 0) {
+		print "\nSkipped:\n";
+		foreach my $show (@skipped) {
+			print $show . "\n";
+		}
+	} else {
+		print "\nNo shows skipped\n";
+	}
+	if (scalar(@monitored) > 0) {
+		print "\nMonitored:\n";
+		foreach my $show (@monitored) {
+			print $show . "\n";
+		}
+	} else {
+		print "\nNo shows monitored\n";
+	}
+}
+
+# Cleanup
+exit(0);

+ 642 - 0
transmission.pl

@@ -0,0 +1,642 @@
+#!/usr/bin/perl
+use warnings;
+use strict;
+
+# Includes
+use JSON;
+use Date::Parse;
+use File::Temp qw/ :mktemp /;
+use File::Basename;
+use lib '/usr/local/spidering/bots/utils';
+use StdBot;
+
+# Prototypes
+sub session($);
+sub fetch($);
+sub getSSE($);
+sub getDest($$$$);
+sub delTor($);
+sub guessExt($);
+sub processFile($$);
+sub unrar($$);
+sub seriesCleanup($);
+sub readDir($$);
+
+# Parameters
+my $maxAge        = 2.5 * 86400;
+my $tvDir         = '/mnt/media/TV';
+my $monitoredExec = '/Users/profplump/bin/video/torrentMonitored.pl';
+my $host          = 'http://localhost:9091';
+my $url           = $host . '/transmission/rpc';
+my $content       = '{"method":"torrent-get","arguments":{"fields":["hashString","id","addedDate","comment","creator","dateCreated","isPrivate","name","totalSize","pieceCount","pieceSize","downloadedEver","error","errorString","eta","haveUnchecked","haveValid","leftUntilDone","metadataPercentComplete","peersConnected","peersGettingFromUs","peersSendingToUs","rateDownload","rateUpload","recheckProgress","sizeWhenDone","status","trackerStats","uploadedEver","uploadRatio","seedRatioLimit","seedRatioMode","downloadDir","files","fileStats"]}}';
+my $delContent    = '{"method":"torrent-remove","arguments":{"ids":["#_ID_#"], "delete-local-data":"true"}';
+my $RAR_MIN_FILES = 4;
+
+# Debug
+my $DEBUG = 0;
+if ($ENV{'DEBUG'}) {
+	$DEBUG = 1;
+}
+
+# Command-line parameters
+my ($force, $maxDays) = @ARGV;
+
+# Init
+my $fetch = Fetch->new();
+
+# Check availability of web interface
+$fetch->url($host);
+if ($DEBUG) {
+	print STDERR "Checking for torrent web interface\n";
+}
+$fetch->fetch('nocheck' => 1);
+if ($fetch->status_code() != 200) {
+	if ($DEBUG) {
+		die("Transmission web interface not available\n");
+	}
+	exit(0);
+}
+
+# Fetch the list of paused (i.e. completed) torrents
+$fetch->url($url);
+$fetch->post_content($content);
+&fetch($fetch);
+
+my $torrents = decode_json($fetch->content());
+$torrents = $torrents->{'arguments'}->{'torrents'};
+
+# Process
+if ($DEBUG) {
+	print STDERR "Processing torrents...\n";
+}
+foreach my $tor (@{$torrents}) {
+
+	# Skip torrents that aren't yet done
+	if ($tor->{'leftUntilDone'} > 0 || $tor->{'metadataPercentComplete'} < 1) {
+		next;
+	}
+
+	# Only process recent files
+	# Torrents created with magnet links have no dateCreated, so fake it with addedDate
+	if ($tor->{'dateCreated'} < 946684800) {
+		$tor->{'dateCreated'} = $tor->{'addedDate'};
+	}
+	if (!$force && $tor->{'dateCreated'} < time() - $maxAge) {
+		my $age = (time() - $tor->{'dateCreated'}) / 86400;
+		if ($DEBUG) {
+			print STDERR 'Skipping old torrent: ' . $tor->{'name'} . ' (' . sprintf('%.0f', $age) . " days)\n";
+		}
+		next;
+	}
+	if ($DEBUG) {
+		print STDERR 'Processing finished torrent: ' . $tor->{'name'} . "\n";
+	}
+
+	# Handle individual files and directories
+	my $file     = '';
+	my @newFiles = ();
+	my $path     = $tor->{'downloadDir'} . '/' . $tor->{'name'};
+	if (-d $path) {
+
+		# State variables
+		my $IS_RAR  = '';
+		my $IS_FAKE = 0;
+
+		# Look for RAR files
+		foreach my $file (@{ $tor->{'files'} }) {
+			if ($file->{'name'} =~ /\.rar$/i) {
+				$IS_RAR = $tor->{'downloadDir'} . '/' . $file->{'name'};
+				last;
+			}
+		}
+
+		# Check for password files
+		foreach my $file (@{ $tor->{'files'} }) {
+			if ($file->{'name'} =~ /passw(?:or)?d/i) {
+				print STDERR 'Password file detected in: ' . $tor->{'name'} . "\n";
+				$IS_FAKE = 1;
+				last;
+			}
+		}
+
+		# Check for single-file RARs
+		if ($IS_RAR && scalar(@{ $tor->{'files'} }) < $RAR_MIN_FILES) {
+			print STDERR 'Single-file RAR detected in: ' . $tor->{'name'} . "\n";
+			$IS_FAKE = 1;
+		}
+
+		# Find media files in valid torrents
+		if (!$IS_FAKE) {
+
+			# Unrar if necessary
+			if (defined($IS_RAR) && length($IS_RAR) > 0) {
+				@newFiles = &unrar($IS_RAR, $path);
+			}
+
+			# Find exactly 1 media file
+			my @files = ();
+			my @tmpFiles = readDir($path, '/\.(?:avi|mkv|m4v|mov|mp4|ts|wmv)$/i');
+			foreach my $file (@tmpFiles) {
+				if ($file =~ /sample/i) {
+					next;
+				}
+				push(@files, $file);
+			}
+			if (scalar(@files) != 1) {
+				print STDERR 'Unable to find a media file in: ' . $path . "\n";
+				next;
+			}
+			$file = $files[0];
+		} else {
+			$file = '';
+		}
+	} else {
+		$file = $path;
+	}
+
+	# Do all the normal file naming/copying/etc. if we found a file
+	my $result = -1;
+	if (defined($file) && length($file) > 0 && -r $file) {
+		$result = &processFile($file, $path);
+	}
+	if (defined($result) && $result == 1) {
+		if ($DEBUG) {
+			print STDERR 'Torrent stored successfully: ' . $tor->{'name'} . "\n";
+		}
+	} elsif (defined($result) && $result == -1) {
+		print STDERR 'Deleting bad torrent: ' . $tor->{'name'} . "\n";
+	} else {
+		print STDERR 'Error storing file "' . basename($file) . '" from torrent: ' . $tor->{'name'} . "\n";
+		next;
+	}
+
+	# Delete any files we added
+	foreach my $file (@newFiles) {
+		if ($DEBUG) {
+			print STDERR 'Deleting new file: ' . basename($file) . "\n";
+		}
+		unlink($file);
+	}
+
+	# Remove the source torrent (it's either copied or bad by this point)
+	delTor($tor);
+}
+
+# Cleanup
+exit(0);
+
+# Grab the session ID
+sub session($) {
+	my ($fetch) = @_;
+	my %headers = ();
+	($headers{'X-Transmission-Session-Id'}) = $fetch->content() =~ /X\-Transmission\-Session\-Id\:\s+(\w+)/;
+	$fetch->headers(\%headers);
+}
+
+sub fetch($) {
+	my ($fetch) = @_;
+	$fetch->fetch('nocheck' => 1);
+	if ($fetch->status_code() != 200) {
+		&session($fetch);
+		StdBot::fetchCheck($fetch, 1);
+	}
+}
+
+sub getSSE($) {
+	my ($name) = @_;
+
+	if ($DEBUG) {
+		print STDERR 'Finding series/season/episode for: ' . $name . "\n";
+	}
+
+	my $season      = 0;
+	my $episode     = 0;
+	my $seasonBlock = '';
+	if ($name =~ /(?:\b|_)(S\d{1,2}[_\s\.]?E\d{1,2})(?:\b|_)/i) {
+		$seasonBlock = $1;
+		($season, $episode) = $seasonBlock =~ /S(\d{1,2})[_\s\.]?E(\d{1,2})/i;
+		$season = int($season);
+		$episode = sprintf('%02d', int($episode));
+	} elsif ($name =~ /[\[\_\.](\d{1,2}x\d{2,3})[\]\_\.]/i) {
+		$seasonBlock = $1;
+		($season, $episode) = $seasonBlock =~ /(\d+)x(\d+)/i;
+		$season = int($season);
+		$episode = sprintf('%02d', int($episode));
+	} elsif ($name =~ /(?:\b|_)(20\d\d(?:\.|\-)[01]?\d(?:\.|\-)[0-3]?\d)(?:\b|_)/) {
+		$seasonBlock = $1;
+		my ($month, $day);
+		($season, $month, $day) = $seasonBlock =~ /(20\d\d)(?:\.|\-)([01]?\d)(?:\.|\-)([0-3]?\d)/;
+		$season = int($season);
+		$episode = sprintf('%04d-%02d-%02d', $season, $month, $day);
+	} elsif ($name =~ /(?:\b|_)([01]?\d[_\s\.]?[0-3]\d)(?:\b|_)/i) {
+		$seasonBlock = $1;
+		($season, $episode) = $seasonBlock =~ /(?:\b|_)([01]?\d)[_\s\.]?([0-3]\d)/i;
+		$season = int($season);
+		$episode = sprintf('%02d', int($episode));
+	}
+	if (!defined($seasonBlock) || $season < 1 || length($episode) < 1 || $episode eq '0') {
+		if ($DEBUG) {
+			print STDERR 'Could not find seasonBlock in: ' . $name . "\n";
+		}
+		return;
+	}
+
+	# Assume the series titles comes before the season/episode block
+	my $series = '';
+	my $sIndex = index($name, $seasonBlock);
+	if ($sIndex > 0) {
+		$series = substr($name, 0, $sIndex - 1);
+		$series = seriesCleanup($series);
+	}
+
+	if ($DEBUG) {
+		print STDERR 'Series: ' . $series . ' Season: ' . $season . ' Episode: ' . $episode . "\n";
+	}
+	return ($series, $season, $episode);
+}
+
+sub getDest($$$$) {
+	my ($series, $season, $episode, $ext) = @_;
+	if ($DEBUG) {
+		print STDERR 'Finding destintion for: ' . $series . ' S' . $season . 'E' . $episode . "\n";
+	}
+
+	# Find all our existing TV series
+	my @shows    = ();
+	my %showsCan = ();
+	open(SHOWS, '-|', $monitoredExec, 'STORE')
+	  or die("Unable to fork: ${!}\n");
+	while (<SHOWS>) {
+		chomp;
+		my $orig  = $_;
+		my $clean = seriesCleanup($_);
+		push(@shows, $clean);
+		$showsCan{$clean} = $orig;
+	}
+	close SHOWS or die("Unable to read monitored series list: ${!} ${?}\n");
+
+	# See if we can find a unique series name match
+	my $sClean = seriesCleanup($series);
+	my $sMatch = '\b' . $sClean . '\b';
+	$series = '';
+
+	foreach my $show (@shows) {
+
+		if ($show =~ /${sMatch}/i) {
+
+			# Enforce any secondary matching rules (for ambiguous titles)
+			my $detail_match = 1;
+			{
+				my @lines;
+				my $file = $tvDir . '/' . $showsCan{$show} . '/must_match';
+				if (-e $file) {
+					if ($DEBUG) {
+						print STDERR 'Reading must_match file: ' . $file . "\n";
+					}
+					local ($/, *FH);
+					open(FH, $file)
+					  or die('Unable to read must_match file: ' . $file . "\n");
+					my @tmp = <FH>;
+					close(FH);
+					push(@lines, @tmp);
+				}
+				foreach my $line (@lines) {
+					chomp($line);
+					if (!eval('$sClean =~ m' . $line)) {
+						if ($DEBUG) {
+							print STDERR 'Skipping ' . $show . ' due to must_match failure for: ' . $line . "\n";
+						}
+						$detail_match = 0;
+						last;
+					}
+				}
+			}
+			if (!$detail_match) {
+				next;
+			}
+
+			# Bail if we find more than one matching series
+			if (length($series) > 0) {
+				print STDERR 'Matched both ' . $series . ' and ' . $show . "\n";
+				return;
+			}
+
+			# If we're still around this is the first series title match
+			$series = $show;
+		}
+	}
+	if (length($series) < 1) {
+		if ($DEBUG) {
+			print STDERR 'No series match for: ' . $sMatch . "\n";
+		}
+		return;
+	}
+
+	# Lookup the canonical name from the matched name
+	$series = $showsCan{$series};
+
+	# Make sure we have the right season folder
+	my $seriesDir = $tvDir . '/' . $series;
+	my $seasonDir = $seriesDir . '/' . 'Season ' . $season;
+	if (!-d $seasonDir) {
+		print STDERR 'No season folder for: ' . $series . ' S' . $season . "\n";
+		return;
+	}
+
+	# Bail if we already have this episode
+	my @episodes = readDir($seasonDir, '/^\s*[\d\-]+\s*\-\s*/');
+	foreach my $ep (@episodes) {
+		$ep = basename($ep);
+		my ($epNum) = $ep =~ /^\s*([\d\-]+)\s*\-\s*/;
+		$epNum   =~ s/^0+//;
+		$episode =~ s/^0+//;
+		if ($epNum eq $episode) {
+			print STDERR 'Existing episode for: ' . $series . ' S' . $season . 'E' . $episode . "\n";
+			return;
+		}
+	}
+
+	# Construct the final path
+	if (!defined($ext) || length($ext) < 1) {
+		$ext = 'avi';
+	}
+	if ($episode =~ /^\d+$/) {
+		$episode = sprintf('%02d', $episode);
+	}
+	my $dest = sprintf('%s/%s - NoName.%s', $seasonDir, $episode, $ext);
+
+	if ($DEBUG) {
+		print STDERR 'Destination: ' . $dest . "\n";
+	}
+	return $dest;
+}
+
+sub delTor($) {
+	my ($tor) = @_;
+	if ($DEBUG) {
+		print STDERR 'Deleting torrent: ' . $tor->{'name'} . "\n";
+	}
+
+	# Construct the content
+	my $id      = $tor->{'hashString'};
+	my $content = $delContent;
+	$content =~ s/\#_ID_\#/${id}/;
+
+	# Send the command
+	&session($fetch);
+	$fetch->post_content($content);
+	&fetch($fetch);
+}
+
+sub guessExt($) {
+	my ($file) = @_;
+	if ($DEBUG) {
+		print STDERR 'Guessing extension for: ' . basename($file) . "\n";
+	}
+
+	# Believe most non-avi file extensions without checking
+	# It's mostly "avi" that lies, and the checks are expensive
+	my $ext = '';
+	{
+		my $orig_ext = basename($file);
+		$orig_ext =~ s/^.*\.(\w{2,3})$/$1/;
+		$orig_ext = lc($orig_ext);
+		if ($orig_ext eq 'mkv' || $orig_ext eq 'ts') {
+			$ext = $orig_ext;
+			if ($DEBUG) {
+				print STDERR 'Accepting declared file extension: ' . $ext . "\n";
+			}
+		}
+	}
+
+	# Ask movInfo.pl about the demuxer
+	if (!$ext) {
+		my $demux = '';
+		open(INFO, '-|', $ENV{'HOME'} . '/bin/video/movInfo.pl', $file, 'DEMUXER');
+		while (<INFO>) {
+			$demux .= $_;
+		}
+		close(INFO);
+		$demux =~ s/^\s+//;
+		$demux =~ s/\s+$//;
+
+		# Grab a MIME type from file(1)
+		my $mime = '';
+		open(FILE, '-|', 'file', '-b', $file);
+		while (<FILE>) {
+			$mime .= $_;
+		}
+		close(FILE);
+		$mime =~ s/^\s+//;
+		$mime =~ s/\s+$//;
+		$mime =~ s/\;.*$//;
+		$mime =~ s/^video\///i;
+
+		# Try to pick an extension we understand
+		$ext = 'avi';
+		if ($demux =~ /mkv/i || $mime =~ /Matroska/i) {
+			$ext = 'mkv';
+		} elsif ($demux =~ /asf/i || $mime =~ /\bASF\b/i) {
+			$ext = 'wmv';
+		} elsif ($mime =~ /\bZIP/i) {
+			$ext = 'zip';
+		} elsif ($mime =~ /\bAVI\b/i) {
+			$ext = 'avi';
+		} elsif ($demux =~ /mpegts/i) {
+			$ext = 'ts';
+		}
+	}
+
+	if ($DEBUG) {
+		print STDERR 'File extension: ' . $ext . "\n";
+	}
+	return $ext;
+}
+
+sub processFile($$) {
+	my ($file, $path) = @_;
+	if ($DEBUG) {
+		print STDERR 'Attempting to process file: ' . basename($file) . "\n";
+	}
+
+	# Guess a file extension
+	my $ext = &guessExt($file);
+
+	# Delete WMV files -- mostly viruses
+	if ($ext =~ /wmv/i) {
+		print STDERR 'Declining to save WMV file: ' . basename($file) . "\n";
+		return -1;
+	}
+
+	# Delete ZIP files -- mostly fake
+	if ($ext =~ /zip/i) {
+		print STDERR 'Declining to save ZIP file: ' . basename($file) . "\n";
+		return -1;
+	}
+
+	# Allow multiple guesses at the series/season/episode
+	my $dest     = '';
+	my $filename = basename($file);
+	LOOP: {
+
+		# Determine the series, season, and episode number
+		my ($series, $season, $episode) = &getSSE($filename);
+		if (   defined($series)
+			&& length($series) > 0
+			&& defined($season)
+			&& $season > 0
+			&& defined($episode)
+			&& length($episode) > 0
+			&& $episode ne '0')
+		{
+
+			# Find the proper destination for this torrent, if any
+			$dest = &getDest($series, $season, $episode, $ext);
+		}
+
+		# Sanity/loop check
+		if (!defined($dest) || length($dest) < 1) {
+
+			# If there is no match but the torrent is a folder
+			# retry the guess using the torrent path
+			if ($file ne $path) {
+				my $newName = basename($path);
+				if ($newName ne $filename) {
+					$filename = basename($path);
+					redo LOOP;
+				}
+			}
+
+			# Otherwise we just fail
+			print STDERR 'No destination for: ' . basename($file) . "\n";
+			return;
+		}
+	}
+
+	# Copy with system tools
+	if ($DEBUG) {
+		print STDERR 'Copying file: ' . basename($file) . "\n";
+	}
+	my $tmp = mktemp($dest . '.XXXXXXXX');
+	Run::runAndCheck(('cp', $file, $tmp));
+	rename($tmp, $dest);
+
+	# Return success
+	return 1;
+}
+
+sub unrar($$) {
+	my ($file, $path) = @_;
+	if ($DEBUG) {
+		print STDERR 'UnRARing: ' . basename($file) . "\n";
+	}
+
+	# Keep a list of old files, so we can find/delete the output
+	my @beforeFiles = readDir($path, undef());
+
+	# Run the unrar utility
+	my $pid = fork();
+	if (!defined($pid)) {
+		print STDERR "Unable to fork for RAR\n";
+		return;
+	} elsif ($pid == 0) {
+
+		# Child
+		close(STDOUT);
+		close(STDERR);
+		chdir($path);
+		my @args = ('unrar', 'e', '-p-', '-y', $file);
+		exec { $args[0] } @args;
+	}
+
+	# Wait for the child
+	waitpid($pid, 0);
+
+	# Compare the old file list to the new one
+	my @newFiles = ();
+	my @afterFiles = readDir($path, undef());
+	foreach my $file (@afterFiles) {
+		my $found = 0;
+		foreach my $file2 (@beforeFiles) {
+			if ($file eq $file2) {
+				$found = 1;
+				last;
+			}
+		}
+		if (!$found) {
+			push(@newFiles, $file);
+		}
+	}
+
+	# Return the list of added files
+	return @newFiles;
+}
+
+sub seriesCleanup($) {
+	my ($name) = @_;
+	$name =~ s/\b(?:and|\&)\b/ /ig;
+	$name =~ s/^\s*The\b//ig;
+	$name =~ s/\bUS\b?\s*$//ig;
+	$name =~ s/\[[^\]]*\]//g;
+	$name =~ s/\([^\)]*\)//g;
+	$name =~ s/\{[^\}]*\}//g;
+	$name =~ s/[\(\)]//g;
+	$name =~ s/[\'\"]//g;
+	$name =~ s/[^\w\s]+/ /g;
+	$name =~ s/\b20[01][0-9]\s*$//;
+	$name =~ s/_+/ /g;
+	$name =~ s/\s+/ /g;
+	$name =~ s/^\s*//;
+	$name =~ s/\s*$//;
+	return $name;
+}
+
+sub readDir($$) {
+	my ($indir, $regex) = @_;
+
+	# Allow optional use of a matching regex
+	my $useRegex = 1;
+	if (!defined($regex) || length($regex) < 1) {
+		$useRegex = 0;
+	}
+
+	# Clean up the input directory
+	$indir =~ s/\/+$//;
+
+	my @files = ();
+	if (!opendir(DIR, $indir)) {
+		warn('readDir: Unable to read directory: ' . $indir . ': ' . $! . "\n");
+		return;
+	}
+	foreach my $file (readdir(DIR)) {
+		my $keep = 0;
+
+		# Regex filter (if active)
+		if (!$useRegex) {
+			$keep = 1;
+		} elsif (eval('$file =~ m' . $regex)) {
+			$keep = 1;
+		}
+
+		# Construct a complete file path
+		$file = $indir . '/' . $file;
+
+		# Ignore directories
+		if (-d $file) {
+			$keep = 0;
+		}
+
+		# Save matching files
+		if ($keep) {
+			push(@files, $file);
+		}
+	}
+	closedir(DIR);
+
+	# Sorts the files before returning to the caller
+	my @orderedFiles = ();
+	@orderedFiles = sort(@files);
+
+	# Return the file list
+	return (@orderedFiles);
+}