Просмотр исходного кода

Merge branch 'master' of github.com:profplump/UberZach-Integration

Zachary Kotlarek 12 лет назад
Родитель
Сommit
59bf362295

+ 6 - 24
dmx/DMX.pm

@@ -23,6 +23,7 @@ chomp($TEMP_DIR);
 my $DATA_DIR    = $TEMP_DIR . 'plexMonitor/';
 my $DMX_SOCK    = 'DMX';
 my $SUB_SOCK    = 'STATE';
+my $SPEAK_SOCK  = undef();
 my %CHANNEL_ADJ = (
 	'13' => 1.00,
 	'14' => 1.14,
@@ -37,7 +38,6 @@ if (!-d $DATA_DIR) {
 # State
 my $DMX_FH = undef();
 my $SELECT = undef();
-my %SAY    = ();
 
 # Data directory
 sub dataDir() {
@@ -254,9 +254,6 @@ sub readState($$$$) {
 	my ($delay, $exists, $mtime, $valid) = @_;
 	my $cmdState = undef();
 
-	# Cleanup previous say calls
-	sayCleanup();
-
 	# Wait for state updates
 	my @ready_clients = $SELECT->can_read($delay);
 	foreach my $fh (@ready_clients) {
@@ -313,14 +310,6 @@ sub readState($$$$) {
 	return $cmdState;
 }
 
-# Reap zombie children
-sub sayCleanup() {
-	foreach my $pid (keys(%SAY)) {
-		waitpid($pid, POSIX::WNOHANG);
-		delete($SAY{$pid});
-	}
-}
-
 # Speak
 sub say($) {
 	my ($str) = @_;
@@ -328,19 +317,12 @@ sub say($) {
 		print STDERR 'Say: ' . $str . "\n";
 	}
 
-	# Cleanup previous say calls
-	sayCleanup();
-
-	# Fork and speak in the background
-	my $pid = fork();
-	if (!defined($pid)) {
-		die('Unable to fork: ' . $! . "\n");
-	}
-	if (!$pid) {
-		exec('say', $str);
-	} else {
-		$SAY{$pid} = 1;
+	if (!$SPEAK_SOCK) {
+		$SPEAK_SOCK = DMX::clientSock('SPEAK');
 	}
+
+	$SPEAK_SOCK->send($str)
+	  or die('Unable to write command to socket: SPEAK: ' . $str . ": ${!}\n");
 }
 
 # Always return true

+ 34 - 5
dmx/audio.pl

@@ -4,6 +4,7 @@ use warnings;
 use File::Temp qw( tempfile );
 use Time::HiRes qw( usleep sleep time );
 use IPC::System::Simple qw( system capture );
+use Time::Out qw( timeout );
 
 # Local modules
 use Cwd qw(abs_path);
@@ -18,6 +19,10 @@ my %DEVS = (
 	'DEFAULT' => 'USB Audio CODEC ',
 );
 
+# Prototypes
+sub getAudio();
+sub setAudio($);
+
 # App config
 my $DATA_DIR     = DMX::dataDir();
 my $STATE_SOCK   = 'AUDIO';
@@ -26,6 +31,7 @@ my $OUTPUT_STATE = $OUTPUT_FILE . '_STATE';
 my $PUSH_TIMEOUT = 20;
 my $PULL_TIMEOUT = $PUSH_TIMEOUT * 3;
 my $DELAY        = 1;
+my $TIMEOUT      = 10;
 my @AUDIO_CMD    = ('/Users/tv/bin/SwitchAudioSource');
 my @AUDIO_GET    = (@AUDIO_CMD, '-c');
 my @AUDIO_SET    = (@AUDIO_CMD, '-s');
@@ -53,15 +59,14 @@ DMX::stateSubscribe($STATE_SOCK);
 # Always force the output to default at launch
 # In most components this happens before state subscription
 # but here we want to avoid making any changes while STATE is not available
-system(@AUDIO_SET, $DEVS{'DEFAULT'});
+setAudio('DEFAULT');
 
 # Loop forever
 while (1) {
 
 	# Grab the current audio output device
 	$deviceLast = $device;
-	$device     = capture(@AUDIO_GET);
-	$device =~ s/\n$//;
+	$device = getAudio();
 
 	# If the device has changed, save the state to disk
 	if ($deviceLast ne $device) {
@@ -95,6 +100,8 @@ while (1) {
 		$state = 'RAVE';
 	} elsif ($exists{'AUDIO_AMP'}) {
 		$state = 'AMP';
+	} elsif (!$exists{'PLEX'}) {
+		$state = 'AMP';
 	} else {
 		$state = 'DEFAULT';
 	}
@@ -120,11 +127,11 @@ while (1) {
 	# Update the audio output device
 	if ($update) {
 
-		# Update
+		# Update, dying on timeout
 		if ($DEBUG) {
 			print STDERR 'Setting output to: ' . $DEVS{$state} . "\n";
 		}
-		system(@AUDIO_SET, $DEVS{$state});
+		setAudio($state);
 
 		# Update the push time
 		$pushLast = time();
@@ -144,3 +151,25 @@ while (1) {
 		rename($tmp, $OUTPUT_STATE);
 	}
 }
+
+sub getAudio() {
+	my $device = '';
+	timeout $TIMEOUT => sub {
+		$device = capture(@AUDIO_GET);
+	};
+	if ($@) {
+		die("Audio get command timed out\n");
+	}
+	$device =~ s/\n$//;
+	return $device;
+}
+
+sub setAudio($) {
+	my ($state) = @_;
+	timeout $TIMEOUT => sub {
+		system(@AUDIO_SET, $DEVS{$state});
+	};
+	if ($@) {
+		die("Audio set command timed out\n");
+	}
+}

+ 7 - 8
dmx/color.pl

@@ -14,9 +14,10 @@ use DMX;
 # Config
 my $DISPLAY  = 1;
 my %PROFILES = (
-	'HIGH' => 'Epson-High',
-	'PLAY' => 'Epson-Theater',
-	'LOW'  => 'Epson-Theater_Black',
+	'DYNAMIC'        => 'Epson-High',
+	'NATURAL'        => 'Epson-High',
+	'THEATER'        => 'Epson-Theater',
+	'THEATER_BLACK'  => 'Epson-Theater_Black',
 );
 
 # App config
@@ -95,12 +96,10 @@ while (1) {
 
 	# Calculate the new state
 	$stateLast = $state;
-	if ($exists{'PROJECTOR_COLOR'} eq 'DYNAMIC') {
-		$state = 'HIGH';
-	} elsif ($exists{'PROJECTOR_COLOR'} eq 'THEATER') {
-		$state = 'PLAY';
+	if (exists($PROFILES{ $exists{'PROJECTOR_COLOR'} })) {
+		$state = $exists{'PROJECTOR_COLOR'};
 	} else {
-		$state = 'LOW';
+		$state = 'THEATER_BLACK';
 	}
 
 	# Force updates on a periodic basis

+ 12 - 7
dmx/leds.pl

@@ -45,6 +45,11 @@ my %DIM            = (
 		{ 'channel' => 14, 'value' => 144, 'time' => 1000 },
 		{ 'channel' => 15, 'value' => 144, 'time' => 1000 },
 	],
+	'BRIGHT'    => [
+		{ 'channel' => 13, 'value' => 255, 'time' => 1000 },
+		{ 'channel' => 14, 'value' => 255, 'time' => 1000 },
+		{ 'channel' => 15, 'value' => 255, 'time' => 1000 },
+	],
 	'ERROR'    => [
 		{ 'channel' => 13, 'value' => 144, 'time' => 0 },
 		{ 'channel' => 14, 'value' => 255, 'time' => 0 },
@@ -127,15 +132,15 @@ while (1) {
 
 	# Calculate the new state
 	$stateLast = $state;
-	if ($exists{'LIGHTS'}) {
+	if ($exists{'BRIGHT'} || $exists{'LIGHTS'}) {
 		if ($newState eq 'PLAY') {
 			$newState = 'PLAY_HIGH';
-		} elsif ($newState eq 'OFF') {
-			$newState = 'MOTION';
-		}
-	} else {
-		if ($newState eq 'PLAY_HIGH') {
-			$newState = 'PLAY';
+		} else {
+			if ($exists{'BRIGHT'}) {
+				$newState = 'BRIGHT';
+			} else {
+				$newState = 'MOTION';
+			}
 		}
 	}
 	$state = $newState;

+ 1 - 0
dmx/modules.txt

@@ -3,3 +3,4 @@ Math::Random
 Time::Out
 File::Touch
 Device::SerialPort
+JSON

+ 16 - 8
dmx/overhead.pl

@@ -15,6 +15,7 @@ my %DIM = (
 	'PLAY_HIGH' => [ { 'channel' => 4, 'value' => 48,  'time' => 500 }, ],
 	'PAUSE'     => [ { 'channel' => 4, 'value' => 96,  'time' => 6000, 'delay' => 9000 }, ],
 	'MOTION'    => [ { 'channel' => 4, 'value' => 128, 'time' => 2500 }, ],
+	'BRIGHT'    => [ { 'channel' => 4, 'value' => 255, 'time' => 2500 }, ],
 	'ERROR'     => [ { 'channel' => 4, 'value' => 255, 'time' => 0 }, ],
 );
 
@@ -85,20 +86,27 @@ while (1) {
 
 	# Calculate the new state
 	$stateLast = $state;
-	if ($exists{'LIGHTS'}) {
+	if ($exists{'LIGHTS'} || $exists{'BRIGHT'}) {
 		if ($newState eq 'PLAY') {
 			$newState = 'PLAY_HIGH';
-		} elsif ($newState eq 'OFF') {
-			$newState = 'MOTION';
-		}
-	} else {
-		if ($newState eq 'PLAY_HIGH') {
-			$newState = 'PLAY';
+		} else {
+			if ($exists{'BRIGHT'}) {
+				$newState = 'BRIGHT';
+			} else {
+				$newState = 'MOTION';
+			}
 		}
 	}
 	$state = $newState;
 
-	# Speak when LIGHTS changes
+	# Speak when LIGHTS or BRIGHT changes
+	if (exists($exists{'BRIGHT'}) && exists($last{'BRIGHT'}) && $exists{'BRIGHT'} ne $last{'BRIGHT'}) {
+		if ($exists{'BRIGHT'}) {
+			DMX::say('Lights - Full power');
+		} else {
+			DMX::say('Lights - Nominal power');
+		}
+	}
 	if (exists($exists{'LIGHTS'}) && exists($last{'LIGHTS'}) && $exists{'LIGHTS'} ne $last{'LIGHTS'}) {
 		if ($exists{'LIGHTS'}) {
 			DMX::say('Lights up');

+ 69 - 21
dmx/projectorPower.pl

@@ -9,14 +9,41 @@ use lib dirname(abs_path($0));
 use DMX;
 
 # Config
-my $TIMEOUT     = 900;
-my $COUNTDOWN   = 119;
-my $OFF_DELAY   = 15;
-my $CMD_DELAY   = 20;
-my $COLOR_HIGH  = 'NATURAL';
-my $COLOR_PLAY  = 'THEATER_BLACK_1';
-my $COLOR_LOW   = 'THEATER_BLACK_1';
-my $LAMP_LIFE   = 2000;
+my $TIMEOUT   = 900;
+my $COUNTDOWN = 119;
+my $OFF_DELAY = 15;
+my $CMD_DELAY = 20;
+
+# Available color modes, bright to dark:
+#	DYNAMIC
+#	NATURAL
+#	THEATER
+#	THEATER_BLACK_1
+#
+# Color modes by proportion of lamp life
+my $LAMP_LIFE = 1500;
+my %COLORS    = (
+	'0.500' => {
+		'high' => 'THEATER',
+		'play' => 'THEATER_BLACK_1',
+		'low'  => 'THEATER_BLACK_1',
+	},
+	'0.600' => {
+		'high' => 'NATURAL',
+		'play' => 'THEATER',
+		'low'  => 'THEATER_BLACK_1',
+	},
+	'0.700' => {
+		'high' => 'DYNAMIC',
+		'play' => 'NATURAL',
+		'low'  => 'THEATER_BLACK_1',
+	},
+	'0.800' => {
+		'high' => 'DYNAMIC',
+		'play' => 'DYNAMIC',
+		'low'  => 'THEATER_BLACK_1',
+	},
+);
 
 # App config
 my $DATA_DIR     = DMX::dataDir();
@@ -52,7 +79,10 @@ my $update       = 0;
 my $lastAnnounce = 0;
 my $lastUser     = time();
 my $shutdown     = 0;
-my $color        = $COLOR_LOW;
+my $life         = 0;
+my @color_sets   = sort { $a <=> $b } keys(%COLORS);
+my $color_set    = $color_sets[0];
+my $color        = $COLORS{$color_set}{'low'};
 
 # Loop forever
 while (1) {
@@ -135,9 +165,28 @@ while (1) {
 		}
 	}
 
+	# Select a color set based on the lamp life
+	if (exists($exists{'PROJECTOR_LAMP'})) {
+		$life = $exists{'PROJECTOR_LAMP'} / $LAMP_LIFE;
+		if ($DEBUG) {
+			print STDERR 'Lamp life: ' . ($life * 100) . "%\n";
+		}
+		my $max_set_num = scalar(@color_sets) - 1;
+		$color_set = $color_sets[$max_set_num];
+		for (my $i = 0 ; $i < $max_set_num ; $i++) {
+			if ($life < $color_sets[$i]) {
+				$color_set = $color_sets[$i];
+				last;
+			}
+		}
+		if ($DEBUG) {
+			print STDERR 'Color set: ' . $color_set . "\n";
+		}
+	}
+
 	# Calculate the color mode
 	# PLAY for normal playback
-	# HIGH when playing and LIGHTS
+	# HIGH when playing and LIGHTS || BRIGHT
 	# LOW for audio, and when we're half way to the timeout (to save the bulb)
 	# HIGH when the GUI is up
 	# PLAY if we haven't figured out what else to do
@@ -145,21 +194,25 @@ while (1) {
 	if ($newState eq 'PLAY') {
 		$playLights = 1;
 	} elsif ($exists{'PLAYING_TYPE'} eq 'Audio') {
-		$color = $COLOR_LOW;
+		$color = $COLORS{$color_set}{'low'};
 	} elsif ($elapsed > $TIMEOUT / 2) {
-		$color = $COLOR_LOW;
+		$color = $COLORS{$color_set}{'low'};
 	} elsif ($exists{'GUI'}) {
-		$color = $COLOR_HIGH;
+		$color = $COLORS{$color_set}{'high'};
 	} else {
 		$playLights = 1;
 	}
+
 	# If playLights was set, choose the color mode based on the LIGHTS setting
 	if ($playLights) {
-		$color = $COLOR_PLAY;
-		if ($exists{'LIGHTS'}) {
-			$color = $COLOR_HIGH;
+		$color = $COLORS{$color_set}{'play'};
+		if ($exists{'LIGHTS'} || $exists{'BRIGHT'}) {
+			$color = $COLORS{$color_set}{'high'};
 		}
 	}
+	if ($DEBUG) {
+		print STDERR 'Selected color: ' . $color . "\n";
+	}
 
 	# Force updates on a periodic basis
 	if (!$update && time() - $pushLast > $PUSH_TIMEOUT) {
@@ -242,11 +295,6 @@ while (1) {
 
 		# Announce lamp life status, if near/past $LAMP_LIFE
 		if (exists($exists{'PROJECTOR_LAMP'})) {
-			my $life = $exists{'PROJECTOR_LAMP'} / $LAMP_LIFE;
-			if ($DEBUG) {
-				print STDERR 'Lamp life: ' . $life . "%\n";
-			}
-
 			if ($life > 0.9) {
 				my $chance = ($life - 0.9) * 10;
 				if (rand(1) <= $chance) {

+ 1 - 1
dmx/reset.sh

@@ -1,6 +1,6 @@
 #!/bin/bash
 
-PROCS="amplifierPower.pl audio.pl bias.pl color.pl debug.pl fan.pl garage.pl leds.pl overhead.pl projectorPower.pl rave.pl rifftrax.pl rope.pl"
+PROCS="amplifierPower.pl audio.pl bias.pl color.pl debug.pl fan.pl garage.pl leds.pl overhead.pl projectorPower.pl rave.pl rifftrax.pl rope.pl frontApp"
 
 # Find the PID for each named process
 PIDS=""

+ 17 - 7
dmx/rope.pl

@@ -60,6 +60,16 @@ my %DIM = (
 		{ 'channel' => 8,  'value' => 96,  'time' => 2000  },
 		{ 'channel' => 9,  'value' => 96,  'time' => 1500  },
 	],
+	'BRIGHT'    => [
+		{ 'channel' => 1,  'value' => 255, 'time' => 1000  },
+		{ 'channel' => 2,  'value' => 255, 'time' => 1000  },
+		{ 'channel' => 3,  'value' => 255, 'time' => 1000  },
+		{ 'channel' => 5,  'value' => 255, 'time' => 1000  },
+		{ 'channel' => 6,  'value' => 255, 'time' => 1000  },
+		{ 'channel' => 7,  'value' => 255, 'time' => 1000  },
+		{ 'channel' => 8,  'value' => 255,  'time' => 2000  },
+		{ 'channel' => 9,  'value' => 255,  'time' => 1500  },
+	],
 	'ERROR'    => [
 		{ 'channel' => 1,  'value' => 0,   'time' => 0  },
 		{ 'channel' => 2,  'value' => 0,   'time' => 0  },
@@ -137,15 +147,15 @@ while (1) {
 
 	# Calculate the new state
 	$stateLast = $state;
-	if ($exists{'LIGHTS'}) {
+	if ($exists{'LIGHTS'} || $exists{'BRIGHT'}) {
 		if ($newState eq 'PLAY') {
 			$newState = 'PLAY_HIGH';
-		} elsif ($newState eq 'OFF') {
-			$newState = 'MOTION';
-		}
-	} else {
-		if ($newState eq 'PLAY_HIGH') {
-			$newState = 'PLAY';
+		} else {
+			if ($exists{'BRIGHT'}) {
+				$newState = 'BRIGHT';
+			} else {
+				$newState = 'MOTION';
+			}
 		}
 	}
 	$state = $newState;

+ 47 - 12
dmx/state.pl

@@ -5,6 +5,7 @@ use IO::Select;
 use File::Basename;
 use File::Temp qw( tempfile );
 use Sys::Hostname;
+use IPC::System::Simple qw( system capture );
 
 # Local modules
 use Cwd qw(abs_path);
@@ -16,7 +17,6 @@ use DMX;
 sub mtime($);
 
 # User config
-my $MEDIA_PATH     = `~/bin/video/mediaPath`;
 my %DISPLAY_DEVS   = ('TV' => 1, 'PROJECTOR' => 1);
 my $DISPLAY        = undef();
 my $STATE_TIMEOUT  = 180;
@@ -27,6 +27,18 @@ my %MON_FILES      = ();
 # This is necessary to avoid contention on shared disks
 my $HOST = Sys::Hostname::hostname();
 if ($HOST =~ /loki/i) {
+
+	# Ensure the media path is available before we get going
+	my $MEDIA_PATH = undef();
+	{
+		my $mount = $ENV{'HOME'} . '/bin/video/mediaPath';
+		$MEDIA_PATH = capture($mount);
+	}
+	if (!$MEDIA_PATH || !-d $MEDIA_PATH) {
+		die("Unable to access media path\n");
+	}
+
+	# Garage door opener
 	$MON_FILES{ $MEDIA_PATH . '/DMX/cmd/GARAGE_CMD' } = 'EXISTS-VALUE-CLEAR';
 }
 
@@ -34,15 +46,16 @@ if ($HOST =~ /loki/i) {
 {
 
 	# Plex
+	$MON_FILES{'PLEX'}    = 'NONE';
 	$MON_FILES{'GUI'}     = 'GUI';
 	$MON_FILES{'PLAYING'} = 'PLAYING';
 
 	# RiffTrax
-	$MON_FILES{'RIFF'} = 'VALUE';
+	$MON_FILES{'RIFF'} = 'VALUE-NOUPDATE';
 
 	# Motion detection
 	$MON_FILES{'MOTION'}    = 'MTIME';
-	$MON_FILES{'NO_MOTION'} = 'EXISTS';
+	$MON_FILES{'NO_MOTION'} = 'EXISTS-NOUPDATE';
 
 	# Projector
 	$MON_FILES{'PROJECTOR'}       = 'STATUS';
@@ -60,6 +73,7 @@ if ($HOST =~ /loki/i) {
 
 	# A/V Effects
 	$MON_FILES{'LIGHTS'} = 'EXISTS-TIMEOUT';
+	$MON_FILES{'BRIGHT'} = 'EXISTS-TIMEOUT';
 	$MON_FILES{'RAVE'}   = 'EXISTS';
 	$MON_FILES{'EFFECT'} = 'EXISTS';
 
@@ -67,9 +81,10 @@ if ($HOST =~ /loki/i) {
 	$MON_FILES{'FAN_CMD'} = 'EXISTS-ON';
 
 	# OS State
-	$MON_FILES{'COLOR'}       = 'VALUE';
-	$MON_FILES{'AUDIO'}       = 'VALUE';
-	$MON_FILES{'AUDIO_STATE'} = 'VALUE';
+	$MON_FILES{'FRONT_APP'}   = 'VALUE-NOUPDATE';
+	$MON_FILES{'COLOR'}       = 'VALUE-NOUPDATE';
+	$MON_FILES{'AUDIO'}       = 'VALUE-NOUPDATE';
+	$MON_FILES{'AUDIO_STATE'} = 'VALUE-NOUPDATE';
 
 	# TV
 	$MON_FILES{'TV'}     = 'STATUS';
@@ -96,7 +111,6 @@ my $DATA_DIR     = DMX::dataDir();
 my $CMD_FILE     = 'STATE';
 my $MAX_CMD_LEN  = 4096;
 my $RESET_CMD    = $ENV{'HOME'} . '/bin/video/dmx/reset.sh';
-my $MEDIA_CMD    = $ENV{'HOME'} . '/bin/video/mountMedia';
 my $PUSH_TIMEOUT = 20;
 
 # Debug
@@ -154,6 +168,7 @@ foreach my $name (keys(%MON_FILES)) {
 		'mtime'     => 0,
 		'gui'       => 0,
 		'playing'   => 0,
+		'no_update' => 0,
 	);
 	if ($file{'type'} =~ /\bSTATUS\b/i) {
 		$attr{'status'} = 1;
@@ -182,6 +197,9 @@ foreach my $name (keys(%MON_FILES)) {
 	if ($file{'type'} =~ /\bPLAYING\b/i) {
 		$attr{'playing'} = 1;
 	}
+	if ($file{'type'} =~ /\bNOUPDATE\b/i) {
+		$attr{'no_update'} = 1;
+	}
 
 	# Cross-match some data types for easy of use
 	if ($attr{'value'} || $attr{'gui'} || $attr{'playing'}) {
@@ -198,9 +216,6 @@ foreach my $name (keys(%MON_FILES)) {
 	$files{$name} = \%file;
 }
 
-# Try to mount the media share
-system($MEDIA_CMD);
-
 # Delete any existing input files, to ensure our state is reset
 foreach my $file (values(%files)) {
 	if (-e $file->{'path'}) {
@@ -414,14 +429,28 @@ while (1) {
 		}
 	}
 
-	# Set the global update timestamp
+	# Set the global update timestamp, excluding files marked NOUPDATE
 	foreach my $file (values(%files)) {
-		if ($file->{'update'} > $updateLast) {
+		if ($file->{'update'} > $updateLast && !$file->{'attr'}->{'no_update'}) {
 			$updateLast = $file->{'update'};
 		}
 	}
 	$timeSinceUpdate = time() - $updateLast;
 
+	# Calculate the PLEX state
+	$files{'PLEX'}->{'last'} = $files{'PLEX'}->{'value'};
+	$files{'PLEX'}->{'value'} = 0;
+	if (exists($files{'FRONT_APP'})) {
+		if (   $files{'FRONT_APP'}->{'value'} eq 'com.plexapp.plex'
+			|| $files{'FRONT_APP'}->{'value'} eq 'com.apple.ScreenSaver.Engine')
+		{
+			$files{'PLEX'}->{'value'} = 1;
+		}
+	}
+	if ($files{'PLEX'}->{'last'} != $files{'PLEX'}->{'value'}) {
+		$files{'PLEX'}->{'update'} = time();
+	}
+
 	# Determine some intermediate state data
 	my $playing = 0;
 	if (exists($files{'PLAYING'}) && $files{'PLAYING'}->{'value'}) {
@@ -434,6 +463,12 @@ while (1) {
 		$video = 1;
 	}
 
+	# Non-plex apps are always "playing" and "video"
+	if (exists($files{'PLEX'}) && !$files{'PLEX'}->{'value'}) {
+		$playing = 1;
+		$video = 1;
+	}
+
 	# Calculate the new state
 	$stateLast = $state;
 	if (!defined($DISPLAY) || !exists($files{$DISPLAY})) {

+ 1 - 0
frontApp/.gitignore

@@ -0,0 +1 @@
+frontApp

+ 7 - 0
frontApp/Makefile

@@ -0,0 +1,7 @@
+default: frontApp
+
+frontApp: frontApp.m
+	gcc -o frontApp -framework Cocoa frontApp.m
+
+clean:
+	rm -f frontApp

+ 24 - 0
frontApp/com.uberzach.frontApp.plist

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>LimitLoadToHosts</key>
+	<array>
+		<string>beddy.uberzach.com</string>
+		<string>loki.uberzach.com</string>
+		<string>heady.uberzach.com</string>
+	</array>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.frontApp</string>
+	<key>KeepAlive</key>
+	<true/>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/frontApp/frontApp</string>
+	</array>
+</dict>
+</plist>

+ 54 - 0
frontApp/frontApp.m

@@ -0,0 +1,54 @@
+#import <Cocoa/Cocoa.h>
+
+@interface MDAppController : NSObject <NSApplicationDelegate>
+@property (retain) NSString *outfile;
+@end
+
+@implementation MDAppController 
+- (id)init {
+    if ((self = [super init])) {
+        [[[NSWorkspace sharedWorkspace] notificationCenter] addObserver:self
+                      selector:@selector(activeAppDidChange:)
+               name:NSWorkspaceDidActivateApplicationNotification object:nil];
+    }
+    return self;
+}
+- (void)dealloc {
+    [[[NSWorkspace sharedWorkspace] notificationCenter] removeObserver:self];
+    [super dealloc];
+}
+- (void)activeAppDidChange:(NSNotification *)notification {
+    NSRunningApplication* runningApp = [[NSWorkspace sharedWorkspace] frontmostApplication];
+	NSString *str = [NSString stringWithFormat:@"%@\n", runningApp.bundleIdentifier];
+	if (![str writeToFile:[self outfile] atomically:YES encoding:NSStringEncodingConversionAllowLossy error:nil]) {
+		NSLog(@"Unable to write to path: %@", [self outfile]);
+	}
+}
+@end
+
+int main(int argc, const char * argv[]) {
+    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
+    [NSApplication sharedApplication];
+
+    MDAppController *appController = [[MDAppController alloc] init];
+    [NSApp setDelegate:appController];
+
+	// Construct a default path or use the provided one
+	if (argc > 1) {
+		NSString *str = [NSString stringWithUTF8String: argv[1]];
+		[appController setOutfile:str];
+	} else {
+		NSString *tmpDir = NSTemporaryDirectory();
+		[appController setOutfile:[NSString stringWithFormat:@"%@plexMonitor/FRONT_APP", tmpDir]];
+	}
+
+	// Force an update at init
+	[appController activeAppDidChange:nil];
+
+	// Wait forever
+    [NSApp run];
+
+	// Never reached
+    [pool release];
+	return 0;
+}

+ 82 - 0
plexCmd/recentlyUnwatched.sh

@@ -0,0 +1,82 @@
+#!/bin/bash
+
+HOST="http://beddy.uberzach.com:32400"
+CURL_OPTS=(--silent --connect-timeout 5 --max-time 10)
+NUM_SERIES=15
+NUM_EPISODES=6
+MAX_RESULTS=100
+
+# Select a configuration mode
+URL1="${HOST}/library/sections/2/onDeck/"
+URL2_POST="children/allLeaves?unwatched=1"
+if echo "${1}" | grep -iq Movie; then
+	URL1="${HOST}/library/sections/1/recentlyAdded/"
+	URL2_POST=""
+fi
+
+# Find recent items
+ITEMS="`curl ${CURL_OPTS[@]} "${URL1}" | \
+	grep '<Video ' | \
+	head -n "${MAX_RESULTS}" | \
+	sed 's%^.* ratingKey="\([0-9]*\)".*$%\1%'`"
+
+# Resolve the series item
+SERIES=""
+IFS=$'\n'
+for i in $ITEMS; do
+	ITEM="`curl ${CURL_OPTS[@]} "${HOST}/library/metadata/${i}/" | \
+		grep '<Video ' | \
+		head -n "${MAX_RESULTS}"`"
+	if echo "${ITEM}" | grep -q 'type="episode"'; then
+		KEY="`echo "${ITEM}" | \
+			sed 's%^.* parentRatingKey="\([0-9]*\)".*$%\1%'`"
+	elif  echo "${ITEM}" | grep -q 'type="movie"'; then
+		KEY="`echo "${ITEM}" | \
+			grep -v 'lastViewedAt="' | \
+			sed 's%^.* ratingKey="\([0-9]*\)".*$%\1%'`"
+	else
+		echo "Unknown type: ${ITEM}" 1>&2
+		exit -1
+	fi
+
+	if [ -n "${KEY}" ]; then
+		SERIES="${SERIES}${KEY}"$'\n'
+	fi
+done
+
+SERIES_COUNT=0
+IFS=$'\n'
+for i in $SERIES; do
+	SERIES_COUNT=$(( $SERIES_COUNT + 1 ))
+	FILES="`curl ${CURL_OPTS[@]} "${HOST}/library/metadata/${i}/${URL2_POST}" | \
+		grep '<Part ' | \
+		head -n "${MAX_RESULTS}" | \
+		sed 's%^.*file="\([^\"]*\)".*$%\1%' | \
+		sed "s%^.*/media/%%"`"
+
+	SEASON_COUNTS=()
+	IFS=$'\n'
+	for j in $FILES; do
+		# Find the season number and increment the count
+		# Account no-season items (i.e. movies) separately
+		SEASON="`echo "${j}" | sed 's%^.*/Season \([0-9]*\)/.*$%\1%'`"
+		if ! echo "${SEASON}" | grep -q '^[0-9]*$'; then
+			SEASON=-1
+		else
+			SEASON_COUNTS[$SEASON]=$(( ${SEASON_COUNTS[$SEASON]} + 1 ))
+		fi
+
+		# Limit the number of series (or movies)
+		if [ $SERIES_COUNT -gt $NUM_SERIES ]; then
+			continue
+		# Only output NUM_EPISODES files per season
+		elif [ $SEASON -ge 0 ] && [ ${SEASON_COUNTS[$SEASON]} -gt $NUM_EPISODES ]; then
+			continue
+		fi
+
+		# Print the name, decoding both hex encoding and XML entities
+		echo "${j}" | \
+			perl -pe 's/%([0-9a-f]{2})/sprintf("%s", pack("H2",$1))/eig' | \
+			perl -e 'use XML::Entities; while (<>) { print XML::Entities::decode('all', $_); }'
+	done
+done

+ 24 - 0
plexMonitor/launchd/com.uberzach.remote.plist

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>LimitLoadToHosts</key>
+	<array>
+		<string>beddy.uberzach.com</string>
+		<string>loki.uberzach.com</string>
+		<string>heady.uberzach.com</string>
+	</array>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.remote</string>
+	<key>KeepAlive</key>
+	<true/>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Applications/Zach/Utilities/Remote Buddy.app/Contents/MacOS/Remote Buddy</string>
+	</array>
+</dict>
+</plist>

+ 17 - 2
pms/PMSCheck.sh

@@ -1,16 +1,21 @@
 #!/bin/bash
 
 # Config
-CURL_TIMEOUT=10
+CURL_TIMEOUT=15
 RESTART_DELAY=300
 PMS_URL="http://localhost:32400/"
 UNWATCHED_SECTION="2"
-UNWATCHED_TIMEOUT="${CURL_TIMEOUT}"
 UNWATCHED_SLEEP=60
 UNWATCHED_RETRIES=5
+UNWATCHED_TIMEOUT_FACTOR=2
 MIN_UNWATCHED_COUNT=10
 ADMIN_EMAIL="zach@kotlarek.com"
 
+# Heady allows 0 unwatched
+if hostname | grep -qi heady; then
+	MIN_UNWATCHED_COUNT=0
+fi
+
 # Command-line config
 LOOP=-1
 if [ "${1}" ]; then
@@ -43,6 +48,7 @@ while [ $LOOP -ne 0 ]; do
 	fi
 
 	# Ask Plex for a list of unwatched TV series
+	UNWATCHED_TIMEOUT=$(( $CURL_TIMEOUT * $UNWATCHED_TIMEOUT_FACTOR ))
 	if [ -z "${FAILED}" ]; then
 		UNWATCHED_URL="${PMS_URL}library/sections/${UNWATCHED_SECTION}/unwatched"
 		TRY=1
@@ -56,6 +62,7 @@ while [ $LOOP -ne 0 ]; do
 			else
 				sleep $UNWATCHED_SLEEP
 				FAILED="${FAILED} ${COUNT}"
+				UNWATCHED_TIMEOUT=$(( $UNWATCHED_TIMEOUT + $(( $CURL_TIMEOUT * $UNWATCHED_TIMEOUT_FACTOR )) ))
 			fi
 		done
 	fi
@@ -75,6 +82,14 @@ while [ $LOOP -ne 0 ]; do
 		# Give plex a breather to get restarted before we check again
 		sleep "${RESTART_DELAY}"
 
+		# Re-index (in the background)
+		~/bin/video/pms/optimize.sh &
+
+	# If Plex was super slow, optimize it
+	elif [ $UNWATCHED_TIMEOUT -gt $(( $CURL_TIMEOUT * $UNWATCHED_TIMEOUT_FACTOR * $(( $UNWATCHED_RETRIES / 2 )) )) ]; then
+		ERR_MSG="PMS is slow (${UNWATCHED_TIMEOUT}). Optimizing..."
+		echo "${ERR_MSG}" 1>&2
+
 		# Re-index (in the background)
 		~/bin/video/pms/optimize.sh &
 	fi

+ 16 - 0
pms/emptyTrash.sh

@@ -0,0 +1,16 @@
+#!/bin/bash
+
+# Bail if the media share isn't mounted
+if ! ~/bin/video/isMediaMounted; then
+	exit 0
+fi
+
+# Bail if we are already scanning
+if ~/bin/video/pms/isScanning.sh; then
+	exit 0
+fi
+
+# Empty the trash for each section
+for i in `~/bin/video/pms/sections.sh`; do
+	curl --silent --upload-file /dev/null "http://localhost:32400/library/sections/${i}/emptyTrash"
+done

+ 21 - 3
pms/scan.sh

@@ -1,5 +1,12 @@
 #!/bin/bash
 
+SCAN_CMD="/Applications/Zach/Media/Plex Media Server.app/Contents/MacOS/Plex Media Scanner"
+
+# Default to "scan" if no action is provided
+if ! echo "${@}" | grep -q -E -- '--(refresh|analyze|index|scan|info|list|generate|tree|reset|add-section|del-section)'; then
+	exec "${0}" '--scan' ${@}
+fi
+
 # Bail if the media share isn't mounted
 if ! ~/bin/video/isMediaMounted; then
 	exit 0
@@ -10,14 +17,25 @@ if ~/bin/video/pms/isScanning.sh; then
 	exit 0
 fi
 
-# Scan, appending any parameters passed to us
-'/Applications/Zach/Media/Plex Media Server.app/Contents/MacOS/Plex Media Scanner' --scan ${@}
+# Scan each section individually if no scope is provided
+if echo "${@}" | grep -q -E -- '--(section|item|directory|file)'; then
+	"${SCAN_CMD}" ${@}
+else
+	for i in `~/bin/video/pms/sections.sh`; do
+		"${SCAN_CMD}" --section "${i}" ${@}
+	done
+fi
 
-# Check the return; kill the PMS if things did not go well
+# Check the return code; kill the PMS if things did not go well
 if [ $? -ne 0 ]; then
 	~/bin/video/pms/killPMS.sh
 fi
 
+# Empty the trash if we scanned and conditions are safe
+if echo "${@}" | grep -q -- '--scan'; then
+	~/bin/video/pms/emptyTrash.sh
+fi
+
 # Force an optimization after deep scans
 if echo "${@}" | grep -q -- '--deep'; then
 	~/bin/video/pms/optimize.sh

+ 4 - 0
pms/sections.sh

@@ -0,0 +1,4 @@
+#!/bin/bash
+
+curl --silent 'http://localhost:32400/library/sections' | \
+	grep '<Directory ' | sed 's%^.* key="\([0-9]*\)".*$%\1%'

+ 3 - 0
speak/.gitignore

@@ -0,0 +1,3 @@
+*.o
+speak
+cli

+ 20 - 0
speak/Makefile

@@ -0,0 +1,20 @@
+STD_FLAGS=-fobjc-arc
+FRAMES=-framework AppKit
+
+default: speak cli
+.PHONY: clean
+
+clean:
+	rm -f *.o speak cli
+
+cli.o: cli.m
+	gcc -c cli.m
+
+cli: cli.o
+	gcc $(FRAMES) -o cli cli.o
+
+speak.o: speak.m
+	gcc $(STD_FLAGS) -c speak.m
+
+speak: speak.o
+	gcc $(FRAMES) $(STD_FLAGS) -o speak speak.m

+ 41 - 0
speak/cli.m

@@ -0,0 +1,41 @@
+#import <AppKit/AppKit.h>
+
+@interface SpeechDelegate : NSObject <NSSpeechSynthesizerDelegate>
+@end
+
+@implementation SpeechDelegate
+- (void)speechSynthesizer: (NSSpeechSynthesizer *)sender didFinishSpeaking:(BOOL)success {
+	if (!success) {
+		exit(-3);
+	}
+	exit(0);
+}
+@end
+
+int main(int argc, const char *argv[]) {
+	if (argc < 2) {
+		return -1;
+	}
+
+	// Init the speech engine
+	NSSpeechSynthesizer *synth = [[NSSpeechSynthesizer alloc] init];
+	[synth setDelegate: [SpeechDelegate new]];
+
+	// Convert from c-string to NSString
+	NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
+	NSString *str = [NSString stringWithUTF8String: argv[1]];
+
+	// Wait for other apps to stop speaking before we start
+	// This provides no cordination with other waiting speakers
+	while( [NSSpeechSynthesizer isAnyApplicationSpeaking] ) {
+		[NSThread sleepForTimeInterval: 0.25];
+	}
+
+	// Speak
+	[synth startSpeakingString: str];
+	[[NSRunLoop currentRunLoop] run];
+
+	// Cleanup (not reached)
+	[pool drain];
+	return 0;
+}

+ 24 - 0
speak/com.uberzach.speak.plist

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>LimitLoadToSessionType</key>
+	<string>Aqua</string>
+	<key>LimitLoadToHosts</key>
+	<array>
+		<string>beddy.uberzach.com</string>
+		<string>loki.uberzach.com</string>
+		<string>heady.uberzach.com</string>
+	</array>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>Label</key>
+	<string>com.uberzach.speak</string>
+	<key>KeepAlive</key>
+	<true/>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/Users/tv/bin/video/speak/speak</string>
+	</array>
+</dict>
+</plist>

+ 8 - 0
speak/say

@@ -0,0 +1,8 @@
+#!/bin/bash
+
+TMPDIR="`tempDir.sh`"
+if [ -z "${TMPDIR}" ] || [ ! -S "${TMPDIR}/plexMonitor/SPEAK.socket" ]; then
+	exec say.orig ${@}
+fi
+
+exec ~/bin/video/dmx/cmd.pl SPEAK "${1}"

+ 113 - 0
speak/speak.m

@@ -0,0 +1,113 @@
+#import <AppKit/AppKit.h>
+#include <sys/types.h>
+#include <sys/un.h>
+#include <sys/socket.h>
+
+// The speach synth is global
+NSSpeechSynthesizer *synth = NULL;
+
+@interface SpeechDelegate : NSObject <NSSpeechSynthesizerDelegate>
+@end
+
+@implementation SpeechDelegate
+- (void)speechSynthesizer: (NSSpeechSynthesizer *)sender didFinishSpeaking:(BOOL)success {
+	if (!success) {
+		NSLog(@"Unable to speak");
+	}
+}
+@end
+
+@interface Socket : NSObject <NSStreamDelegate>
+- (BOOL)listen:(NSString *)path;
+static void sockCallback (CFSocketRef sock, CFSocketCallBackType type, CFDataRef address, const void *data, void *info);
+
+@property NSString *path;
+@property CFSocketRef unixSocket;
+@end
+
+@implementation Socket
+
+- (BOOL)listen:(NSString *)path {
+	self.path = path;
+	
+	unlink([self.path cStringUsingEncoding:NSUTF8StringEncoding]);
+	CFSocketContext CTX = { 0, (__bridge void *)(self), NULL, NULL, NULL };
+
+	self.unixSocket = CFSocketCreate(NULL, PF_UNIX, SOCK_DGRAM, 0,
+		kCFSocketDataCallBack, (CFSocketCallBack)sockCallback, &CTX);
+	if (self.unixSocket == NULL) {
+		return NO;
+	} 
+
+	struct sockaddr_un addr;
+	memset(&addr, 0, sizeof(addr));
+	addr.sun_family = AF_UNIX;
+	strncpy(addr.sun_path, [self.path cStringUsingEncoding:NSUTF8StringEncoding], sizeof(addr.sun_path) - 1);
+	addr.sun_len = SUN_LEN(&addr);
+
+	NSData *address = [ NSData dataWithBytes: &addr length: sizeof(addr) ];
+	if (CFSocketSetAddress(self.unixSocket, (__bridge CFDataRef) address) != kCFSocketSuccess) {
+		CFRelease(self.unixSocket);
+		return NO;
+	}
+
+	CFRunLoopSourceRef sourceRef = CFSocketCreateRunLoopSource(kCFAllocatorDefault, self.unixSocket, 0);
+	CFRunLoopAddSource(CFRunLoopGetCurrent(), sourceRef, kCFRunLoopCommonModes);
+	CFRelease(sourceRef);  
+	return YES;
+}
+
+static void sockCallback (CFSocketRef sock, CFSocketCallBackType type, CFDataRef address, const void *data, void *info) {
+	switch(type) {
+		case kCFSocketDataCallBack: {
+			// Wait for other apps to stop speaking before we start
+			// This provides no cordination with other waiting speakers
+			while( [NSSpeechSynthesizer isAnyApplicationSpeaking] ) {
+				[NSThread sleepForTimeInterval: 0.25];
+			}
+
+			// Speak
+			NSString *str = [[NSString alloc] initWithData:(__bridge NSData*)data encoding:NSUTF8StringEncoding];
+			[synth startSpeakingString: str];
+			break;			
+		}
+		case kCFSocketNoCallBack:
+		case kCFSocketAcceptCallBack:
+		case kCFSocketConnectCallBack:
+		case kCFSocketWriteCallBack:
+		case kCFSocketReadCallBack: {
+			break;
+		}
+	}
+}
+
+@end
+
+int main(int argc, const char *argv[]) {
+
+	// Construct a default socket path or use the provided one
+	NSString *str;
+	if (argc > 1) {
+		str = [NSString stringWithUTF8String: argv[1]];
+	} else {
+		NSString *tmpDir = NSTemporaryDirectory();
+		str = [NSString stringWithFormat:@"%@plexMonitor/SPEAK.socket", tmpDir];
+	}
+	
+	// Init the speech engine
+	synth = [[NSSpeechSynthesizer alloc] init];
+	[synth setDelegate: [SpeechDelegate new]];
+	
+	// Open the socket
+	Socket *sock = [[Socket alloc] init];
+	if (![sock listen:str]) {
+		NSLog(@"Unable to open socket");
+		exit(-1);
+	}	
+
+	// Run
+	[[NSRunLoop currentRunLoop] run];
+
+	// Cleanup (not reached)
+	return 0;
+}