| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575 |
- #!/usr/bin/perl
- use strict;
- use warnings;
- use File::Temp qw( tempfile );
- use Scalar::Util qw( refaddr );
- use Time::HiRes qw( usleep sleep time );
- use IPC::System::Simple qw( system capture );
- # Local modules
- use Cwd qw(abs_path);
- use File::Basename qw(dirname);
- use lib dirname(abs_path($0));
- use DMX;
- use Audio;
- # Config
- my $MEDIA_PATH = `~/bin/video/mediaPath`;
- my $CONFIG_PATH = $MEDIA_PATH . '/DMX/RiffTrax';
- my $ACTION_DELAY = 0.1;
- my $JUMP_THRESHOLD = 5;
- my $LEADIN_TIME = 8;
- my $VOLUME_RIFF = 65;
- my $VOLUME_STD = 40;
- my $VOL_STEP = 1 / 20;
- my $CONFIG_DELAY = 300;
- # Prototypes
- sub playRiff();
- sub pauseRiff();
- sub getRiffRate();
- sub setRiffRate($);
- sub parseConfig($);
- sub playPausePlex();
- sub matchRiff($$$);
- # App config
- my $DATA_DIR = DMX::dataDir();
- my $STATE_SOCK = 'RIFF';
- my $OUTPUT_FILE = $DATA_DIR . $STATE_SOCK;
- my $PULL_TIMEOUT = 60;
- my $DELAY = $PULL_TIMEOUT / 3;
- my %ATTRS = (
- 'name' => { 'dmx' => 'PLAYING_TITLE', 'regex' => qr/^\s*Name:[ \t]*(\S.*\S)\s*$/mi },
- 'year' => { 'dmx' => 'PLAYING_YEAR', 'regex' => qr/^\s*Year:[ \t]*(\d{4})\s*$/mi },
- 'series' => { 'dmx' => 'PLAYING_SERIES', 'regex' => qr/^\s*Series:[ \t]*(\S.*\S)\s*$/mi },
- 'season' => { 'dmx' => 'PLAYING_SEASON', 'regex' => qr/^\s*Season:[ \t]*(\d+)\s*$/mi },
- 'episode' => { 'dmx' => 'PLAYING_EPISODE', 'regex' => qr/^\s*Episode:[ \t]*(\d+)\s*$/mi },
- 'file' => { 'regex' => qr/^\s*File:[ \t]*(\S.*\S)\s*$/mi },
- 'offset' => { 'regex' => qr/^\s*Offset:[ \t]*([\-\+]?\d+(?:\.\d+)?)\s*$/mi },
- );
- # Debug
- my $DEBUG = 0;
- if ($ENV{'DEBUG'}) {
- $DEBUG = 1;
- }
- # Sockets
- DMX::stateSocket($STATE_SOCK);
- DMX::stateSubscribe($STATE_SOCK);
- # State
- my $state = 'OFF';
- my $stateLast = $state;
- my $riff = '';
- my $riffLast = $riff;
- my $title = '';
- my $titleLast = $title;
- my %exists = ();
- my %last = ();
- my $pullLast = time();
- my $lastSync = time();
- my $playing = 0;
- my $delay = $DELAY;
- my $leadin = 0;
- my $reload = 0;
- # Read the config
- my $RIFFS = parseConfig($CONFIG_PATH);
- # Loop forever
- while (1) {
- # Save the last RIFF and state
- $stateLast = $state;
- $state = '';
- $riffLast = $riff;
- %last = %exists;
- # Reconnect if we're playing and haven't seen an update for $JUMP_THRESHOLD seconds
- # During leadin this leads to a number of reconnects, but that's preferable to missing a disconnect
- if ($playing && time() - $pullLast > $JUMP_THRESHOLD) {
- print STDERR "Attempting to reconnect state socket...\n";
- DMX::stateSubscribe($STATE_SOCK);
- }
- # Wait for state updates
- my $cmdState = DMX::readState($delay, \%exists, undef(), undef());
- if (defined($cmdState)) {
- $state = $cmdState;
- # Don't count HUP as a valid update
- if ($state ne 'HUP') {
- $pullLast = time();
- }
- }
- # Die if we don't see regular updates
- if (time() - $pullLast > $PULL_TIMEOUT) {
- die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
- }
- # Re-read the config on-demand
- if ($state eq 'HUP') {
- $RIFFS = parseConfig($CONFIG_PATH);
- $reload = 1;
- # Skip further processing this loop -- the exists hash is useless
- next;
- }
- # Re-read the config periodically (unless we are currently playing)
- if (!$riff && time() > $RIFFS->{'_LAST_CONFIG_UPDATE'} + $CONFIG_DELAY) {
- $RIFFS = parseConfig($CONFIG_PATH);
- }
- # Handle volume changes
- if ($riff) {
- if ($state eq 'VOL+' || $state eq 'VOL-') {
- # Calculate a relative volume
- my $vol = Audio::volume('RIFF', undef());
- if ($state eq 'VOL+') {
- $vol += $VOL_STEP;
- } else {
- $vol -= $VOL_STEP;
- }
- # Limit the range to 0.0 - 1.0
- if ($vol < 0) {
- $vol = 0;
- }
- if ($vol > 1) {
- $vol = 1;
- }
- # Set the document volume
- Audio::volume('RIFF', $vol);
- # Skip further processing this loop -- the exists hash is useless
- next;
- }
- }
- # Save the last URL, so we can find changes
- # Do not delete the last URL if no new one is provided
- if (exists($exists{'PLAYING_TITLE'})) {
- $titleLast = $title;
- $title = $exists{'PLAYING_TITLE'};
- }
- my $titleChanged = 0;
- if ($title ne $titleLast) {
- $titleChanged = 1;
- }
- # Convert the video timestamp to seconds
- my $videoTime = 0;
- if ($exists{'PLAYING_POSITION'}) {
- my @parts = split(/\:/, $exists{'PLAYING_POSITION'});
- $videoTime = 0;
- if (scalar(@parts) == 3) {
- $videoTime = ($parts[0] * 3600) + ($parts[1] * 60) + $parts[2];
- } elsif (scalar(@parts) == 2) {
- $videoTime = ($parts[0] * 60) + $parts[1];
- } else {
- $videoTime = $parts[0];
- }
- }
- # If a RIFF was active and the title changed, clear it
- if ($riff && $titleChanged) {
- if ($DEBUG) {
- print STDERR "RIFF complete\n";
- }
- # Increase the delay while nothing is happening
- $delay = $DELAY;
- # Reset the volume when we unload -- normal system sounds should be quieter than riffs
- Audio::systemVolume($VOLUME_STD);
- # Close the audio file
- Audio::unload('RIFF');
- $riff = undef();
- $leadin = 0;
- # Warm fuzzies
- DMX::say('RiffTrax complete');
- }
- # Look for new matches when the title changes or a reload is requested
- if ($titleChanged || $reload) {
- $riff = matchRiff($RIFFS, \%ATTRS, \%exists);
- $reload = 0;
- }
- # If we matched a riff and it isn't active, load it
- if (defined($riff) && !defined($riffLast)) {
- # Warm fuzzies
- DMX::say('RiffTrax initiated');
- # Enable LEADIN if we're near the beginning of the movie
- if ($riff->{'offset'} > $LEADIN_TIME && $videoTime < $LEADIN_TIME) {
- playPausePlex();
- $leadin = 1;
- }
- # Load and start the audio file
- Audio::load('RIFF', $riff->{'file'});
- playRiff();
- # Set volume when we load -- riffs should be louder than normal system sounds
- Audio::systemVolume($VOLUME_RIFF);
- Audio::volume('RIFF', 1.0);
- # Reduce the delay while playing so we sync faster
- $delay = 1;
- }
- # If the RIFF has changed, save the state to disk
- if ( (defined($riff) != defined($riffLast))
- || (defined($riff) && ref($riff) && defined($riffLast) && ref($riffLast) && refaddr($riff) != refaddr($riffLast)))
- {
- my $new = '<none>';
- if ($riff) {
- $new = $riff->{'name'};
- }
- my $old = $new;
- if ($riffLast) {
- $old = $riffLast->{'name'};
- }
- if ($DEBUG) {
- print STDERR 'New RIFF: ' . $old . ' => ' . $new . "\n";
- }
- my ($fh, $tmp) = tempfile($OUTPUT_FILE . '.XXXXXXXX', 'UNLINK' => 0);
- print $fh $new . "\n";
- close($fh);
- rename($tmp, $OUTPUT_FILE);
- }
- # Skip further processing if we don't have valid state data from Plex
- if (!exists($exists{'PLAYING'})) {
- next;
- }
- # Play/pause to match the video or LEADIN state
- if ($riff) {
- if ($leadin) {
- if (!$playing) {
- playRiff();
- }
- } elsif (!$exists{'PLAYING'} && $playing) {
- pauseRiff();
- } elsif ($exists{'PLAYING'} && !$playing) {
- playRiff();
- }
- }
- # Skip further processing if we don't have valid sync data from Plex
- if (!$videoTime) {
- next;
- }
- # Sync at most once per second
- if ($riff && $lastSync < time()) {
- if ($DEBUG) {
- print STDERR "Syncing\n";
- }
- $lastSync = time();
- # Get the riff playback state
- my $rate = getRiffRate();
- my $riffTime = Audio::position('RIFF', undef());
- # Update our local playing state tracker
- # The rate is always 0 when paused (Audio::playing uses this same methodology)
- if ($rate > 0) {
- $playing = 1;
- } else {
- $playing = 0;
- }
- # Calculate the adjusted riff time and the error between the riff and video times
- my $riffAdjTime = $riffTime - $riff->{'offset'};
- my $error = $videoTime - $riffAdjTime;
- my $errorAbs = abs($error);
- # Debug
- if ($DEBUG) {
- print STDERR "\tRate: " . $rate . "\n";
- print STDERR "\tOffset: " . $riff->{'offset'} . "\n";
- print STDERR "\tRiff time: " . $riffTime . "\n";
- print STDERR "\tVideo time: " . $videoTime . "\n";
- print STDERR "\tAdjusted riff time: " . $riffAdjTime . "\n";
- print STDERR "\tError: " . $error . "\n";
- print STDERR "\tLeadin: " . $leadin . "\n";
- }
- # If LEADIN is still active
- if ($leadin) {
- # Keep Plex awake
- if (int($riffTime) % 10 == 0) {
- ticklePlex();
- }
- # Deactivate LEADIN if the movie advances outside the LEADIN_TIME window
- if ($videoTime > $LEADIN_TIME) {
- $leadin = 0;
- if ($DEBUG) {
- print STDERR "LEADIN canceled due to video time\n";
- }
- }
- # Start the movie when we reach the sync point
- if ($riff->{'offset'} <= $riffTime) {
- if (!$exists{'PLAYING'}) {
- playPausePlex();
- }
- $leadin = 0;
- if ($DEBUG) {
- print STDERR "LEADIN canceled due to synchronization\n";
- }
- }
- # Skip normal syncing until LEADIN is cleared
- if ($leadin) {
- next;
- }
- }
- # Adjust the riff rate/position if we're off by 0.5 seconds or more
- if ($errorAbs > 0.5) {
- # Calculate the new position, including an ACTION_DELAY adjustment to compensate for time elapsed in this process
- my $newPos = $riffTime + $error + $ACTION_DELAY;
- if ($newPos < 0) {
- pauseRiff();
- } else {
- # Jump if we're off by more than a few seconds
- if ($errorAbs > $JUMP_THRESHOLD) {
- if ($DEBUG) {
- print STDERR 'Jumping to: ' . $newPos . "\n";
- }
- Audio::position('RIFF', $newPos);
- $error = 0;
- $errorAbs = 0;
- }
- # Set the rate when we adjust, unless we're paused
- if ($playing) {
- my $rateDiff = $errorAbs / $JUMP_THRESHOLD;
- my $newRate = 1.0;
- if ($error < 0) {
- $newRate -= $rateDiff;
- } else {
- $newRate += $rateDiff;
- }
- if ($DEBUG) {
- print STDERR 'Setting rate to: ' . $newRate . "\n";
- }
- setRiffRate($newRate);
- }
- }
- } else {
- # Reset the rate to standard if we're inside sync the window
- if ($rate != 1.0) {
- setRiffRate(1.0);
- }
- }
- }
- }
- sub playRiff() {
- if ($DEBUG) {
- print STDERR "playRiff()\n";
- }
- $playing = 1;
- Audio::background('RIFF');
- }
- sub pauseRiff() {
- if ($DEBUG) {
- print STDERR "pauseRiff()\n";
- }
- $playing = 0;
- Audio::pause('RIFF');
- }
- sub getRiffRate() {
- if ($DEBUG) {
- print STDERR "getRiffRate()\n";
- }
- my $rate = 0;
- if ($playing) {
- $rate = Audio::rate('RIFF', undef());
- }
- return $rate;
- }
- sub setRiffRate($) {
- my ($rate) = @_;
- if ($DEBUG) {
- print STDERR 'setRiffRate(): ' . $rate . "\n";
- }
- if ($playing) {
- Audio::rate('RIFF', $rate);
- }
- }
- sub parseConfig($) {
- my ($conf_path) = @_;
- if ($DEBUG) {
- print STDERR "parseConfig()\n";
- }
- my %riffs = ();
- opendir(CONF, $conf_path)
- or die('Unable to open config directory: ' . $! . "\n");
- foreach my $file (readdir(CONF)) {
- # Skip silly files
- if ($file =~ /^\._/) {
- next;
- }
- if ($file =~ /\.riff$/) {
- my $path = $conf_path . '/' . $file;
- # Slurp the contents
- my $text = '';
- if (!open(my $fh, $path)) {
- warn('Unable to open ' . $path . "\n");
- next;
- } else {
- local $/;
- $text = <$fh>;
- close($fh);
- }
- # Parse out the data we care about
- my %data = ();
- foreach my $attr (keys(%ATTRS)) {
- if (!exists($ATTRS{$attr}{'regex'})) {
- next;
- }
- if ($text =~ $ATTRS{$attr}{'regex'}) {
- $data{$attr} = $1;
- }
- }
- # Ensure we have a valid record
- if (!$data{'name'}) {
- warn('Invalid riff file: ' . $file . "\n");
- next;
- }
- # Try to find a related file
- if (!$data{'file'}) {
- $data{'file'} = $file;
- $data{'file'} =~ s/\.riff$/\.mp3/i;
- $data{'file'} = $conf_path . '/' . $data{'file'};
- }
- # Ensure the path is valid
- if (!-r $data{'file'}) {
- warn('Invalid MP3 file in riff ' . $file . ' => ' . $data{'file'} . "\n");
- next;
- }
- # Debug
- if ($DEBUG) {
- print STDERR 'Added RiffTrax: ' . $file . "\n";
- foreach my $attr (keys(%ATTRS)) {
- if ($data{$attr}) {
- print STDERR "\t" . $attr . ': ' . $data{$attr} . "\n";
- }
- }
- }
- # Push the data up the chain
- if (!exists($riffs{ $data{'name'} })) {
- my @tmp = ();
- $riffs{ $data{'name'} } = \@tmp;
- }
- push(@{ $riffs{ $data{'name'} } }, \%data);
- }
- }
- closedir(CONF);
- # Record the last update time
- $riffs{'_LAST_CONFIG_UPDATE'} = time();
- return \%riffs;
- }
- # The UDP listener would be more portable, but I already know how to do this
- # The PMS playback API would work too, but this script currently knows nothing of the PMS
- sub playPausePlex() {
- my @cmd = ('tell application "OpenPHT" to activate', 'tell application "System Events" to key code 49');
- Audio::runApplescript(join("\n", @cmd));
- }
- # Send a noop keycode to Plex to fend off the screen saver
- sub ticklePlex() {
- my @cmd = ('tell application "OpenPHT" to activate', 'tell application "System Events" to key code 1');
- Audio::runApplescript(join("\n", @cmd));
- }
- # Determine if anything matches the currently playing media
- sub matchRiff($$$) {
- my ($riffs, $attrs, $dmx) = @_;
- my $match = undef();
- # Quick check for a title match
- if (!$riffs->{ $dmx->{'PLAYING_TITLE'} }) {
- return;
- }
- if ($DEBUG) {
- print STDERR 'Matched RIFF title: ' . $dmx->{'PLAYING_TITLE'} . "\n";
- }
- # Find the best available match among media with identical titles
- my $matchAttrCount = 0;
- foreach my $data (@{ $riffs->{ $dmx->{'PLAYING_TITLE'} } }) {
- my $valid = 1;
- my $attrCount = 0;
- if ($DEBUG) {
- print STDERR '';
- }
- foreach my $attr (keys(%{$attrs})) {
- if (!exists($data->{$attr}) || !exists($attrs->{$attr}{'dmx'})) {
- next;
- }
- if ($data->{$attr} eq $dmx->{ $attrs->{$attr}{'dmx'} }) {
- if ($DEBUG) {
- print STDERR "\t\tMatched attr: " . $attr . ' => ' . $data->{$attr} . "\n";
- }
- $attrCount++;
- } else {
- if ($DEBUG) {
- print STDERR "\t\tFailed match for attr: " . $attr . ' => ' . $data->{$attr} . "\n";
- }
- $valid = 0;
- last;
- }
- }
- # Record the match if it's better than previous matches
- if ($valid && $attrCount > $matchAttrCount) {
- $match = $data;
- $matchAttrCount = $attrCount;
- }
- }
- return $match;
- }
|