rifftrax.pl 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. #!/usr/bin/perl
  2. use strict;
  3. use warnings;
  4. use File::Temp qw( tempfile );
  5. use Scalar::Util qw( refaddr );
  6. use Time::HiRes qw( usleep sleep time );
  7. use IPC::System::Simple qw( system capture );
  8. # Local modules
  9. use Cwd qw(abs_path);
  10. use File::Basename qw(dirname);
  11. use lib dirname(abs_path($0));
  12. use DMX;
  13. use Audio;
  14. # Config
  15. my $MEDIA_PATH = `~/bin/video/mediaPath`;
  16. my $CONFIG_PATH = $MEDIA_PATH . '/DMX/RiffTrax';
  17. my $ACTION_DELAY = 0.1;
  18. my $JUMP_THRESHOLD = 5;
  19. my $LEADIN_TIME = 8;
  20. my $VOLUME_RIFF = 65;
  21. my $VOLUME_STD = 40;
  22. my $VOL_STEP = 1 / 20;
  23. my $CONFIG_DELAY = 300;
  24. # Prototypes
  25. sub playRiff();
  26. sub pauseRiff();
  27. sub getRiffRate();
  28. sub setRiffRate($);
  29. sub parseConfig($);
  30. sub playPausePlex();
  31. sub matchRiff($$$);
  32. # App config
  33. my $DATA_DIR = DMX::dataDir();
  34. my $STATE_SOCK = 'RIFF';
  35. my $OUTPUT_FILE = $DATA_DIR . $STATE_SOCK;
  36. my $PULL_TIMEOUT = 60;
  37. my $DELAY = $PULL_TIMEOUT / 3;
  38. my %ATTRS = (
  39. 'name' => { 'dmx' => 'PLAYING_TITLE', 'regex' => qr/^\s*Name:[ \t]*(\S.*\S)\s*$/mi },
  40. 'year' => { 'dmx' => 'PLAYING_YEAR', 'regex' => qr/^\s*Year:[ \t]*(\d{4})\s*$/mi },
  41. 'series' => { 'dmx' => 'PLAYING_SERIES', 'regex' => qr/^\s*Series:[ \t]*(\S.*\S)\s*$/mi },
  42. 'season' => { 'dmx' => 'PLAYING_SEASON', 'regex' => qr/^\s*Season:[ \t]*(\d+)\s*$/mi },
  43. 'episode' => { 'dmx' => 'PLAYING_EPISODE', 'regex' => qr/^\s*Episode:[ \t]*(\d+)\s*$/mi },
  44. 'file' => { 'regex' => qr/^\s*File:[ \t]*(\S.*\S)\s*$/mi },
  45. 'offset' => { 'regex' => qr/^\s*Offset:[ \t]*([\-\+]?\d+(?:\.\d+)?)\s*$/mi },
  46. );
  47. # Debug
  48. my $DEBUG = 0;
  49. if ($ENV{'DEBUG'}) {
  50. $DEBUG = 1;
  51. }
  52. # Sockets
  53. DMX::stateSocket($STATE_SOCK);
  54. DMX::stateSubscribe($STATE_SOCK);
  55. # State
  56. my $state = 'OFF';
  57. my $stateLast = $state;
  58. my $riff = '';
  59. my $riffLast = $riff;
  60. my $title = '';
  61. my $titleLast = $title;
  62. my %exists = ();
  63. my %last = ();
  64. my $pullLast = time();
  65. my $lastSync = time();
  66. my $playing = 0;
  67. my $delay = $DELAY;
  68. my $leadin = 0;
  69. my $reload = 0;
  70. # Read the config
  71. my $RIFFS = parseConfig($CONFIG_PATH);
  72. # Loop forever
  73. while (1) {
  74. # Save the last RIFF and state
  75. $stateLast = $state;
  76. $state = '';
  77. $riffLast = $riff;
  78. %last = %exists;
  79. # Reconnect if we're playing and haven't seen an update for $JUMP_THRESHOLD seconds
  80. # During leadin this leads to a number of reconnects, but that's preferable to missing a disconnect
  81. if ($playing && time() - $pullLast > $JUMP_THRESHOLD) {
  82. print STDERR "Attempting to reconnect state socket...\n";
  83. DMX::stateSubscribe($STATE_SOCK);
  84. }
  85. # Wait for state updates
  86. my $cmdState = DMX::readState($delay, \%exists, undef(), undef());
  87. if (defined($cmdState)) {
  88. $state = $cmdState;
  89. # Don't count HUP as a valid update
  90. if ($state ne 'HUP') {
  91. $pullLast = time();
  92. }
  93. }
  94. # Die if we don't see regular updates
  95. if (time() - $pullLast > $PULL_TIMEOUT) {
  96. die('No update on state socket in past ' . $PULL_TIMEOUT . " seconds. Exiting...\n");
  97. }
  98. # Re-read the config on-demand
  99. if ($state eq 'HUP') {
  100. $RIFFS = parseConfig($CONFIG_PATH);
  101. $reload = 1;
  102. # Skip further processing this loop -- the exists hash is useless
  103. next;
  104. }
  105. # Re-read the config periodically (unless we are currently playing)
  106. if (!$riff && time() > $RIFFS->{'_LAST_CONFIG_UPDATE'} + $CONFIG_DELAY) {
  107. $RIFFS = parseConfig($CONFIG_PATH);
  108. }
  109. # Handle volume changes
  110. if ($riff) {
  111. if ($state eq 'VOL+' || $state eq 'VOL-') {
  112. # Calculate a relative volume
  113. my $vol = Audio::volume('RIFF', undef());
  114. if ($state eq 'VOL+') {
  115. $vol += $VOL_STEP;
  116. } else {
  117. $vol -= $VOL_STEP;
  118. }
  119. # Limit the range to 0.0 - 1.0
  120. if ($vol < 0) {
  121. $vol = 0;
  122. }
  123. if ($vol > 1) {
  124. $vol = 1;
  125. }
  126. # Set the document volume
  127. Audio::volume('RIFF', $vol);
  128. # Skip further processing this loop -- the exists hash is useless
  129. next;
  130. }
  131. }
  132. # Save the last URL, so we can find changes
  133. # Do not delete the last URL if no new one is provided
  134. if (exists($exists{'PLAYING_TITLE'})) {
  135. $titleLast = $title;
  136. $title = $exists{'PLAYING_TITLE'};
  137. }
  138. my $titleChanged = 0;
  139. if ($title ne $titleLast) {
  140. $titleChanged = 1;
  141. }
  142. # Convert the video timestamp to seconds
  143. my $videoTime = 0;
  144. if ($exists{'PLAYING_POSITION'}) {
  145. my @parts = split(/\:/, $exists{'PLAYING_POSITION'});
  146. $videoTime = 0;
  147. if (scalar(@parts) == 3) {
  148. $videoTime = ($parts[0] * 3600) + ($parts[1] * 60) + $parts[2];
  149. } elsif (scalar(@parts) == 2) {
  150. $videoTime = ($parts[0] * 60) + $parts[1];
  151. } else {
  152. $videoTime = $parts[0];
  153. }
  154. }
  155. # If a RIFF was active and the title changed, clear it
  156. if ($riff && $titleChanged) {
  157. if ($DEBUG) {
  158. print STDERR "RIFF complete\n";
  159. }
  160. # Increase the delay while nothing is happening
  161. $delay = $DELAY;
  162. # Reset the volume when we unload -- normal system sounds should be quieter than riffs
  163. Audio::systemVolume($VOLUME_STD);
  164. # Close the audio file
  165. Audio::unload('RIFF');
  166. $riff = undef();
  167. $leadin = 0;
  168. # Warm fuzzies
  169. DMX::say('RiffTrax complete');
  170. }
  171. # Look for new matches when the title changes or a reload is requested
  172. if ($titleChanged || $reload) {
  173. $riff = matchRiff($RIFFS, \%ATTRS, \%exists);
  174. $reload = 0;
  175. }
  176. # If we matched a riff and it isn't active, load it
  177. if (defined($riff) && !defined($riffLast)) {
  178. # Warm fuzzies
  179. DMX::say('RiffTrax initiated');
  180. # Enable LEADIN if we're near the beginning of the movie
  181. if ($riff->{'offset'} > $LEADIN_TIME && $videoTime < $LEADIN_TIME) {
  182. playPausePlex();
  183. $leadin = 1;
  184. }
  185. # Load and start the audio file
  186. Audio::load('RIFF', $riff->{'file'});
  187. playRiff();
  188. # Set volume when we load -- riffs should be louder than normal system sounds
  189. Audio::systemVolume($VOLUME_RIFF);
  190. Audio::volume('RIFF', 1.0);
  191. # Reduce the delay while playing so we sync faster
  192. $delay = 1;
  193. }
  194. # If the RIFF has changed, save the state to disk
  195. if ( (defined($riff) != defined($riffLast))
  196. || (defined($riff) && ref($riff) && defined($riffLast) && ref($riffLast) && refaddr($riff) != refaddr($riffLast)))
  197. {
  198. my $new = '<none>';
  199. if ($riff) {
  200. $new = $riff->{'name'};
  201. }
  202. my $old = $new;
  203. if ($riffLast) {
  204. $old = $riffLast->{'name'};
  205. }
  206. if ($DEBUG) {
  207. print STDERR 'New RIFF: ' . $old . ' => ' . $new . "\n";
  208. }
  209. my ($fh, $tmp) = tempfile($OUTPUT_FILE . '.XXXXXXXX', 'UNLINK' => 0);
  210. print $fh $new . "\n";
  211. close($fh);
  212. rename($tmp, $OUTPUT_FILE);
  213. }
  214. # Skip further processing if we don't have valid state data from Plex
  215. if (!exists($exists{'PLAYING'})) {
  216. next;
  217. }
  218. # Play/pause to match the video or LEADIN state
  219. if ($riff) {
  220. if ($leadin) {
  221. if (!$playing) {
  222. playRiff();
  223. }
  224. } elsif (!$exists{'PLAYING'} && $playing) {
  225. pauseRiff();
  226. } elsif ($exists{'PLAYING'} && !$playing) {
  227. playRiff();
  228. }
  229. }
  230. # Skip further processing if we don't have valid sync data from Plex
  231. if (!$videoTime) {
  232. next;
  233. }
  234. # Sync at most once per second
  235. if ($riff && $lastSync < time()) {
  236. if ($DEBUG) {
  237. print STDERR "Syncing\n";
  238. }
  239. $lastSync = time();
  240. # Get the riff playback state
  241. my $rate = getRiffRate();
  242. my $riffTime = Audio::position('RIFF', undef());
  243. # Update our local playing state tracker
  244. # The rate is always 0 when paused (Audio::playing uses this same methodology)
  245. if ($rate > 0) {
  246. $playing = 1;
  247. } else {
  248. $playing = 0;
  249. }
  250. # Calculate the adjusted riff time and the error between the riff and video times
  251. my $riffAdjTime = $riffTime - $riff->{'offset'};
  252. my $error = $videoTime - $riffAdjTime;
  253. my $errorAbs = abs($error);
  254. # Debug
  255. if ($DEBUG) {
  256. print STDERR "\tRate: " . $rate . "\n";
  257. print STDERR "\tOffset: " . $riff->{'offset'} . "\n";
  258. print STDERR "\tRiff time: " . $riffTime . "\n";
  259. print STDERR "\tVideo time: " . $videoTime . "\n";
  260. print STDERR "\tAdjusted riff time: " . $riffAdjTime . "\n";
  261. print STDERR "\tError: " . $error . "\n";
  262. print STDERR "\tLeadin: " . $leadin . "\n";
  263. }
  264. # If LEADIN is still active
  265. if ($leadin) {
  266. # Keep Plex awake
  267. if (int($riffTime) % 10 == 0) {
  268. ticklePlex();
  269. }
  270. # Deactivate LEADIN if the movie advances outside the LEADIN_TIME window
  271. if ($videoTime > $LEADIN_TIME) {
  272. $leadin = 0;
  273. if ($DEBUG) {
  274. print STDERR "LEADIN canceled due to video time\n";
  275. }
  276. }
  277. # Start the movie when we reach the sync point
  278. if ($riff->{'offset'} <= $riffTime) {
  279. if (!$exists{'PLAYING'}) {
  280. playPausePlex();
  281. }
  282. $leadin = 0;
  283. if ($DEBUG) {
  284. print STDERR "LEADIN canceled due to synchronization\n";
  285. }
  286. }
  287. # Skip normal syncing until LEADIN is cleared
  288. if ($leadin) {
  289. next;
  290. }
  291. }
  292. # Adjust the riff rate/position if we're off by 0.5 seconds or more
  293. if ($errorAbs > 0.5) {
  294. # Calculate the new position, including an ACTION_DELAY adjustment to compensate for time elapsed in this process
  295. my $newPos = $riffTime + $error + $ACTION_DELAY;
  296. if ($newPos < 0) {
  297. pauseRiff();
  298. } else {
  299. # Jump if we're off by more than a few seconds
  300. if ($errorAbs > $JUMP_THRESHOLD) {
  301. if ($DEBUG) {
  302. print STDERR 'Jumping to: ' . $newPos . "\n";
  303. }
  304. Audio::position('RIFF', $newPos);
  305. $error = 0;
  306. $errorAbs = 0;
  307. }
  308. # Set the rate when we adjust, unless we're paused
  309. if ($playing) {
  310. my $rateDiff = $errorAbs / $JUMP_THRESHOLD;
  311. my $newRate = 1.0;
  312. if ($error < 0) {
  313. $newRate -= $rateDiff;
  314. } else {
  315. $newRate += $rateDiff;
  316. }
  317. if ($DEBUG) {
  318. print STDERR 'Setting rate to: ' . $newRate . "\n";
  319. }
  320. setRiffRate($newRate);
  321. }
  322. }
  323. } else {
  324. # Reset the rate to standard if we're inside sync the window
  325. if ($rate != 1.0) {
  326. setRiffRate(1.0);
  327. }
  328. }
  329. }
  330. }
  331. sub playRiff() {
  332. if ($DEBUG) {
  333. print STDERR "playRiff()\n";
  334. }
  335. $playing = 1;
  336. Audio::background('RIFF');
  337. }
  338. sub pauseRiff() {
  339. if ($DEBUG) {
  340. print STDERR "pauseRiff()\n";
  341. }
  342. $playing = 0;
  343. Audio::pause('RIFF');
  344. }
  345. sub getRiffRate() {
  346. if ($DEBUG) {
  347. print STDERR "getRiffRate()\n";
  348. }
  349. my $rate = 0;
  350. if ($playing) {
  351. $rate = Audio::rate('RIFF', undef());
  352. }
  353. return $rate;
  354. }
  355. sub setRiffRate($) {
  356. my ($rate) = @_;
  357. if ($DEBUG) {
  358. print STDERR 'setRiffRate(): ' . $rate . "\n";
  359. }
  360. if ($playing) {
  361. Audio::rate('RIFF', $rate);
  362. }
  363. }
  364. sub parseConfig($) {
  365. my ($conf_path) = @_;
  366. if ($DEBUG) {
  367. print STDERR "parseConfig()\n";
  368. }
  369. my %riffs = ();
  370. opendir(CONF, $conf_path)
  371. or die('Unable to open config directory: ' . $! . "\n");
  372. foreach my $file (readdir(CONF)) {
  373. # Skip silly files
  374. if ($file =~ /^\._/) {
  375. next;
  376. }
  377. if ($file =~ /\.riff$/) {
  378. my $path = $conf_path . '/' . $file;
  379. # Slurp the contents
  380. my $text = '';
  381. if (!open(my $fh, $path)) {
  382. warn('Unable to open ' . $path . "\n");
  383. next;
  384. } else {
  385. local $/;
  386. $text = <$fh>;
  387. close($fh);
  388. }
  389. # Parse out the data we care about
  390. my %data = ();
  391. foreach my $attr (keys(%ATTRS)) {
  392. if (!exists($ATTRS{$attr}{'regex'})) {
  393. next;
  394. }
  395. if ($text =~ $ATTRS{$attr}{'regex'}) {
  396. $data{$attr} = $1;
  397. }
  398. }
  399. # Ensure we have a valid record
  400. if (!$data{'name'}) {
  401. warn('Invalid riff file: ' . $file . "\n");
  402. next;
  403. }
  404. # Try to find a related file
  405. if (!$data{'file'}) {
  406. $data{'file'} = $file;
  407. $data{'file'} =~ s/\.riff$/\.mp3/i;
  408. $data{'file'} = $conf_path . '/' . $data{'file'};
  409. }
  410. # Ensure the path is valid
  411. if (!-r $data{'file'}) {
  412. warn('Invalid MP3 file in riff ' . $file . ' => ' . $data{'file'} . "\n");
  413. next;
  414. }
  415. # Debug
  416. if ($DEBUG) {
  417. print STDERR 'Added RiffTrax: ' . $file . "\n";
  418. foreach my $attr (keys(%ATTRS)) {
  419. if ($data{$attr}) {
  420. print STDERR "\t" . $attr . ': ' . $data{$attr} . "\n";
  421. }
  422. }
  423. }
  424. # Push the data up the chain
  425. if (!exists($riffs{ $data{'name'} })) {
  426. my @tmp = ();
  427. $riffs{ $data{'name'} } = \@tmp;
  428. }
  429. push(@{ $riffs{ $data{'name'} } }, \%data);
  430. }
  431. }
  432. closedir(CONF);
  433. # Record the last update time
  434. $riffs{'_LAST_CONFIG_UPDATE'} = time();
  435. return \%riffs;
  436. }
  437. # The UDP listener would be more portable, but I already know how to do this
  438. # The PMS playback API would work too, but this script currently knows nothing of the PMS
  439. sub playPausePlex() {
  440. my @cmd = ('tell application "OpenPHT" to activate', 'tell application "System Events" to key code 49');
  441. Audio::runApplescript(join("\n", @cmd));
  442. }
  443. # Send a noop keycode to Plex to fend off the screen saver
  444. sub ticklePlex() {
  445. my @cmd = ('tell application "OpenPHT" to activate', 'tell application "System Events" to key code 1');
  446. Audio::runApplescript(join("\n", @cmd));
  447. }
  448. # Determine if anything matches the currently playing media
  449. sub matchRiff($$$) {
  450. my ($riffs, $attrs, $dmx) = @_;
  451. my $match = undef();
  452. # Quick check for a title match
  453. if (!$riffs->{ $dmx->{'PLAYING_TITLE'} }) {
  454. return;
  455. }
  456. if ($DEBUG) {
  457. print STDERR 'Matched RIFF title: ' . $dmx->{'PLAYING_TITLE'} . "\n";
  458. }
  459. # Find the best available match among media with identical titles
  460. my $matchAttrCount = 0;
  461. foreach my $data (@{ $riffs->{ $dmx->{'PLAYING_TITLE'} } }) {
  462. my $valid = 1;
  463. my $attrCount = 0;
  464. if ($DEBUG) {
  465. print STDERR '';
  466. }
  467. foreach my $attr (keys(%{$attrs})) {
  468. if (!exists($data->{$attr}) || !exists($attrs->{$attr}{'dmx'})) {
  469. next;
  470. }
  471. if ($data->{$attr} eq $dmx->{ $attrs->{$attr}{'dmx'} }) {
  472. if ($DEBUG) {
  473. print STDERR "\t\tMatched attr: " . $attr . ' => ' . $data->{$attr} . "\n";
  474. }
  475. $attrCount++;
  476. } else {
  477. if ($DEBUG) {
  478. print STDERR "\t\tFailed match for attr: " . $attr . ' => ' . $data->{$attr} . "\n";
  479. }
  480. $valid = 0;
  481. last;
  482. }
  483. }
  484. # Record the match if it's better than previous matches
  485. if ($valid && $attrCount > $matchAttrCount) {
  486. $match = $data;
  487. $matchAttrCount = $attrCount;
  488. }
  489. }
  490. return $match;
  491. }