transmission.pl 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. #!/usr/bin/perl
  2. use warnings;
  3. use strict;
  4. # Includes
  5. use JSON;
  6. use Date::Parse;
  7. use File::Temp qw/ :mktemp /;
  8. use File::Basename;
  9. use FindBin qw($Bin);
  10. use lib $Bin;
  11. use Run;
  12. use Fetch;
  13. # Prototypes
  14. sub session($);
  15. sub fetch($);
  16. sub getSSE($);
  17. sub getDest($$$$);
  18. sub delTor($);
  19. sub guessExt($);
  20. sub processFile($$);
  21. sub unrar($$);
  22. sub seriesCleanup($);
  23. sub readDir($$);
  24. # Parameters
  25. my $maxAge = 2.5 * 86400;
  26. my $tvDir = '/mnt/media/TV';
  27. my $monitoredExec = '/Users/profplump/bin/video/torrentMonitored.pl';
  28. my $host = 'http://localhost:9091';
  29. my $url = $host . '/transmission/rpc';
  30. 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"]}}';
  31. my $delContent = '{"method":"torrent-remove","arguments":{"ids":["#_ID_#"], "delete-local-data":"true"}';
  32. my $RAR_MIN_FILES = 4;
  33. # Debug
  34. my $DEBUG = 0;
  35. if ($ENV{'DEBUG'}) {
  36. $DEBUG = 1;
  37. }
  38. # Command-line parameters
  39. my ($force, $maxDays) = @ARGV;
  40. # Init
  41. my $fetch = Fetch->new();
  42. # Check availability of web interface
  43. $fetch->url($host);
  44. if ($DEBUG) {
  45. print STDERR "Checking for torrent web interface\n";
  46. }
  47. $fetch->fetch('nocheck' => 1);
  48. if ($fetch->status_code() != 200) {
  49. if ($DEBUG) {
  50. die("Transmission web interface not available\n");
  51. }
  52. exit(0);
  53. }
  54. # Fetch the list of paused (i.e. completed) torrents
  55. $fetch->url($url);
  56. $fetch->post_content($content);
  57. &fetch($fetch);
  58. my $torrents = decode_json($fetch->content());
  59. $torrents = $torrents->{'arguments'}->{'torrents'};
  60. # Process
  61. if ($DEBUG) {
  62. print STDERR "Processing torrents...\n";
  63. }
  64. foreach my $tor (@{$torrents}) {
  65. # Skip torrents that aren't yet done
  66. if ($tor->{'leftUntilDone'} > 0 || $tor->{'metadataPercentComplete'} < 1) {
  67. next;
  68. }
  69. # Only process recent files
  70. # Torrents created with magnet links have no dateCreated, so fake it with addedDate
  71. if ($tor->{'dateCreated'} < 946684800) {
  72. $tor->{'dateCreated'} = $tor->{'addedDate'};
  73. }
  74. if (!$force && $tor->{'dateCreated'} < time() - $maxAge) {
  75. my $age = (time() - $tor->{'dateCreated'}) / 86400;
  76. if ($DEBUG) {
  77. print STDERR 'Skipping old torrent: ' . $tor->{'name'} . ' (' . sprintf('%.0f', $age) . " days)\n";
  78. }
  79. next;
  80. }
  81. if ($DEBUG) {
  82. print STDERR 'Processing finished torrent: ' . $tor->{'name'} . "\n";
  83. }
  84. # Handle individual files and directories
  85. my $file = '';
  86. my @newFiles = ();
  87. my $path = $tor->{'downloadDir'} . '/' . $tor->{'name'};
  88. if (-d $path) {
  89. # State variables
  90. my $IS_RAR = '';
  91. my $IS_FAKE = 0;
  92. # Look for RAR files
  93. foreach my $file (@{ $tor->{'files'} }) {
  94. if ($file->{'name'} =~ /\.rar$/i) {
  95. $IS_RAR = $tor->{'downloadDir'} . '/' . $file->{'name'};
  96. last;
  97. }
  98. }
  99. # Check for password files
  100. foreach my $file (@{ $tor->{'files'} }) {
  101. if ($file->{'name'} =~ /passw(?:or)?d/i) {
  102. print STDERR 'Password file detected in: ' . $tor->{'name'} . "\n";
  103. $IS_FAKE = 1;
  104. last;
  105. }
  106. }
  107. # Check for single-file RARs
  108. if ($IS_RAR && scalar(@{ $tor->{'files'} }) < $RAR_MIN_FILES) {
  109. print STDERR 'Single-file RAR detected in: ' . $tor->{'name'} . "\n";
  110. $IS_FAKE = 1;
  111. }
  112. # Find media files in valid torrents
  113. if (!$IS_FAKE) {
  114. # Unrar if necessary
  115. if (defined($IS_RAR) && length($IS_RAR) > 0) {
  116. @newFiles = &unrar($IS_RAR, $path);
  117. }
  118. # Find exactly 1 media file
  119. my @files = ();
  120. my @tmpFiles = readDir($path, '/\.(?:avi|mkv|m4v|mov|mp4|ts|wmv)$/i');
  121. foreach my $file (@tmpFiles) {
  122. if ($file =~ /sample/i) {
  123. next;
  124. }
  125. push(@files, $file);
  126. }
  127. if (scalar(@files) != 1) {
  128. print STDERR 'Unable to find a media file in: ' . $path . "\n";
  129. next;
  130. }
  131. $file = $files[0];
  132. } else {
  133. $file = '';
  134. }
  135. } else {
  136. $file = $path;
  137. }
  138. # Do all the normal file naming/copying/etc. if we found a file
  139. my $result = -1;
  140. if (defined($file) && length($file) > 0 && -r $file) {
  141. $result = &processFile($file, $path);
  142. }
  143. if (defined($result) && $result == 1) {
  144. if ($DEBUG) {
  145. print STDERR 'Torrent stored successfully: ' . $tor->{'name'} . "\n";
  146. }
  147. } elsif (defined($result) && $result == -1) {
  148. print STDERR 'Deleting bad torrent: ' . $tor->{'name'} . "\n";
  149. } else {
  150. print STDERR 'Error storing file "' . basename($file) . '" from torrent: ' . $tor->{'name'} . "\n";
  151. next;
  152. }
  153. # Delete any files we added
  154. foreach my $file (@newFiles) {
  155. if ($DEBUG) {
  156. print STDERR 'Deleting new file: ' . basename($file) . "\n";
  157. }
  158. unlink($file);
  159. }
  160. # Remove the source torrent (it's either copied or bad by this point)
  161. delTor($tor);
  162. }
  163. # Cleanup
  164. exit(0);
  165. # Grab the session ID
  166. sub session($) {
  167. my ($fetch) = @_;
  168. my %headers = ();
  169. ($headers{'X-Transmission-Session-Id'}) = $fetch->content() =~ /X\-Transmission\-Session\-Id\:\s+(\w+)/;
  170. $fetch->headers(\%headers);
  171. }
  172. sub fetch($) {
  173. my ($fetch) = @_;
  174. $fetch->fetch('nocheck' => 1);
  175. if ($fetch->status_code() != 200) {
  176. &session($fetch);
  177. $fetch->fetch();
  178. if ($fetch->status_code() != 200) {
  179. die('Unable to fetch: ' . $fetch->status_code() . "\n");
  180. }
  181. }
  182. }
  183. sub getSSE($) {
  184. my ($name) = @_;
  185. if ($DEBUG) {
  186. print STDERR 'Finding series/season/episode for: ' . $name . "\n";
  187. }
  188. my $season = 0;
  189. my $episode = 0;
  190. my $seasonBlock = '';
  191. if ($name =~ /(?:\b|_)(S\d{1,2}[_\s\.]?E\d{1,2})(?:\b|_)/i) {
  192. $seasonBlock = $1;
  193. ($season, $episode) = $seasonBlock =~ /S(\d{1,2})[_\s\.]?E(\d{1,2})/i;
  194. $season = int($season);
  195. $episode = sprintf('%02d', int($episode));
  196. } elsif ($name =~ /[\[\_\.](\d{1,2}x\d{2,3})[\]\_\.]/i) {
  197. $seasonBlock = $1;
  198. ($season, $episode) = $seasonBlock =~ /(\d+)x(\d+)/i;
  199. $season = int($season);
  200. $episode = sprintf('%02d', int($episode));
  201. } elsif ($name =~ /(?:\b|_)(20\d\d(?:\.|\-)[01]?\d(?:\.|\-)[0-3]?\d)(?:\b|_)/) {
  202. $seasonBlock = $1;
  203. my ($month, $day);
  204. ($season, $month, $day) = $seasonBlock =~ /(20\d\d)(?:\.|\-)([01]?\d)(?:\.|\-)([0-3]?\d)/;
  205. $season = int($season);
  206. $episode = sprintf('%04d-%02d-%02d', $season, $month, $day);
  207. } elsif ($name =~ /(?:\b|_)([01]?\d[_\s\.]?[0-3]\d)(?:\b|_)/i) {
  208. $seasonBlock = $1;
  209. ($season, $episode) = $seasonBlock =~ /(?:\b|_)([01]?\d)[_\s\.]?([0-3]\d)/i;
  210. $season = int($season);
  211. $episode = sprintf('%02d', int($episode));
  212. }
  213. if (!defined($seasonBlock) || $season < 1 || length($episode) < 1 || $episode eq '0') {
  214. if ($DEBUG) {
  215. print STDERR 'Could not find seasonBlock in: ' . $name . "\n";
  216. }
  217. return;
  218. }
  219. # Assume the series titles comes before the season/episode block
  220. my $series = '';
  221. my $sIndex = index($name, $seasonBlock);
  222. if ($sIndex > 0) {
  223. $series = substr($name, 0, $sIndex - 1);
  224. $series = seriesCleanup($series);
  225. }
  226. if ($DEBUG) {
  227. print STDERR 'Series: ' . $series . ' Season: ' . $season . ' Episode: ' . $episode . "\n";
  228. }
  229. return ($series, $season, $episode);
  230. }
  231. sub getDest($$$$) {
  232. my ($series, $season, $episode, $ext) = @_;
  233. if ($DEBUG) {
  234. print STDERR 'Finding destintion for: ' . $series . ' S' . $season . 'E' . $episode . "\n";
  235. }
  236. # Find all our existing TV series
  237. my @shows = ();
  238. my %showsCan = ();
  239. open(SHOWS, '-|', $monitoredExec, 'STORE')
  240. or die("Unable to fork: ${!}\n");
  241. while (<SHOWS>) {
  242. chomp;
  243. my $orig = $_;
  244. my $clean = seriesCleanup($_);
  245. push(@shows, $clean);
  246. $showsCan{$clean} = $orig;
  247. }
  248. close SHOWS or die("Unable to read monitored series list: ${!} ${?}\n");
  249. # See if we can find a unique series name match
  250. my $sClean = seriesCleanup($series);
  251. my $sMatch = '\b' . $sClean . '\b';
  252. $series = '';
  253. foreach my $show (@shows) {
  254. if ($show =~ /${sMatch}/i) {
  255. # Enforce any secondary matching rules (for ambiguous titles)
  256. my $detail_match = 1;
  257. {
  258. my @lines;
  259. my $file = $tvDir . '/' . $showsCan{$show} . '/must_match';
  260. if (-e $file) {
  261. local ($/, *FH);
  262. open(FH, $file)
  263. or die('Unable to read must_match file: ' . $file . "\n");
  264. my @tmp = <FH>;
  265. close(FH);
  266. push(@lines, @tmp);
  267. }
  268. foreach my $line (@lines) {
  269. chomp($line);
  270. if (!eval('$sClean =~ m' . $line)) {
  271. if ($DEBUG) {
  272. print STDERR 'Skipping ' . $show . ' due to must_match failure for: ' . $line . "\n";
  273. }
  274. $detail_match = 0;
  275. last;
  276. }
  277. }
  278. }
  279. if (!$detail_match) {
  280. next;
  281. }
  282. # Bail if we find more than one matching series
  283. if (length($series) > 0) {
  284. print STDERR 'Matched both ' . $series . ' and ' . $show . "\n";
  285. return;
  286. }
  287. # If we're still around this is the first series title match
  288. $series = $show;
  289. }
  290. }
  291. if (length($series) < 1) {
  292. if ($DEBUG) {
  293. print STDERR 'No series match for: ' . $sMatch . "\n";
  294. }
  295. return;
  296. }
  297. # Lookup the canonical name from the matched name
  298. $series = $showsCan{$series};
  299. # Make sure we have the right season folder
  300. my $seriesDir = $tvDir . '/' . $series;
  301. my $seasonDir = $seriesDir . '/' . 'Season ' . $season;
  302. if (!-d $seasonDir) {
  303. print STDERR 'No season folder for: ' . $series . ' S' . $season . "\n";
  304. return;
  305. }
  306. # Bail if we already have this episode
  307. my @episodes = readDir($seasonDir, '/^\s*[\d\-]+\s*\-\s*/');
  308. foreach my $ep (@episodes) {
  309. $ep = basename($ep);
  310. my ($epNum) = $ep =~ /^\s*([\d\-]+)\s*\-\s*/;
  311. $epNum =~ s/^0+//;
  312. $episode =~ s/^0+//;
  313. if ($epNum eq $episode) {
  314. print STDERR 'Existing episode for: ' . $series . ' S' . $season . 'E' . $episode . "\n";
  315. return;
  316. }
  317. }
  318. # Construct the final path
  319. if (!defined($ext) || length($ext) < 1) {
  320. $ext = 'avi';
  321. }
  322. if ($episode =~ /^\d+$/) {
  323. $episode = sprintf('%02d', $episode);
  324. }
  325. my $dest = sprintf('%s/%s - NoName.%s', $seasonDir, $episode, $ext);
  326. if ($DEBUG) {
  327. print STDERR 'Destination: ' . $dest . "\n";
  328. }
  329. return $dest;
  330. }
  331. sub delTor($) {
  332. my ($tor) = @_;
  333. if ($DEBUG) {
  334. print STDERR 'Deleting torrent: ' . $tor->{'name'} . "\n";
  335. }
  336. # Construct the content
  337. my $id = $tor->{'hashString'};
  338. my $content = $delContent;
  339. $content =~ s/\#_ID_\#/${id}/;
  340. # Send the command
  341. &session($fetch);
  342. $fetch->post_content($content);
  343. &fetch($fetch);
  344. }
  345. sub guessExt($) {
  346. my ($file) = @_;
  347. # Believe most non-avi file extensions without checking
  348. # It's mostly "avi" that lies, and the checks are expensive
  349. my $ext = '';
  350. {
  351. my $orig_ext = basename($file);
  352. $orig_ext =~ s/^.*\.(\w{2,3})$/$1/;
  353. $orig_ext = lc($orig_ext);
  354. if ($orig_ext eq 'mkv' || $orig_ext eq 'ts') {
  355. $ext = $orig_ext;
  356. }
  357. }
  358. # Ask movInfo.pl about the demuxer
  359. if (!$ext) {
  360. my $demux = '';
  361. open(INFO, '-|', $ENV{'HOME'} . '/bin/video/movInfo.pl', $file, 'DEMUXER');
  362. while (<INFO>) {
  363. $demux .= $_;
  364. }
  365. close(INFO);
  366. $demux =~ s/^\s+//;
  367. $demux =~ s/\s+$//;
  368. # Grab a MIME type from file(1)
  369. my $mime = '';
  370. open(FILE, '-|', 'file', '-b', $file);
  371. while (<FILE>) {
  372. $mime .= $_;
  373. }
  374. close(FILE);
  375. $mime =~ s/^\s+//;
  376. $mime =~ s/\s+$//;
  377. $mime =~ s/\;.*$//;
  378. $mime =~ s/^video\///i;
  379. # Try to pick an extension we understand
  380. $ext = 'avi';
  381. if ($demux =~ /mkv/i || $mime =~ /Matroska/i) {
  382. $ext = 'mkv';
  383. } elsif ($demux =~ /asf/i || $mime =~ /\bASF\b/i) {
  384. $ext = 'wmv';
  385. } elsif ($mime =~ /\bZIP/i) {
  386. $ext = 'zip';
  387. } elsif ($mime =~ /\bAVI\b/i) {
  388. $ext = 'avi';
  389. } elsif ($demux =~ /mpegts/i) {
  390. $ext = 'ts';
  391. }
  392. }
  393. return $ext;
  394. }
  395. sub processFile($$) {
  396. my ($file, $path) = @_;
  397. if ($DEBUG) {
  398. print STDERR 'Attempting to process file: ' . basename($file) . "\n";
  399. }
  400. # Guess a file extension
  401. my $ext = &guessExt($file);
  402. # Delete WMV files -- mostly viruses
  403. if ($ext =~ /wmv/i) {
  404. print STDERR 'Declining to save WMV file: ' . basename($file) . "\n";
  405. return -1;
  406. }
  407. # Delete ZIP files -- mostly fake
  408. if ($ext =~ /zip/i) {
  409. print STDERR 'Declining to save ZIP file: ' . basename($file) . "\n";
  410. return -1;
  411. }
  412. # Allow multiple guesses at the series/season/episode
  413. my $dest = '';
  414. my $filename = basename($file);
  415. LOOP: {
  416. # Determine the series, season, and episode number
  417. my ($series, $season, $episode) = &getSSE($filename);
  418. if ( defined($series)
  419. && length($series) > 0
  420. && defined($season)
  421. && $season > 0
  422. && defined($episode)
  423. && length($episode) > 0
  424. && $episode ne '0')
  425. {
  426. # Find the proper destination for this torrent, if any
  427. $dest = &getDest($series, $season, $episode, $ext);
  428. }
  429. # Sanity/loop check
  430. if (!defined($dest) || length($dest) < 1) {
  431. # If there is no match but the torrent is a folder
  432. # retry the guess using the torrent path
  433. if ($file ne $path) {
  434. my $newName = basename($path);
  435. if ($newName ne $filename) {
  436. $filename = basename($path);
  437. redo LOOP;
  438. }
  439. }
  440. # Otherwise we just fail
  441. print STDERR 'No destination for: ' . basename($file) . "\n";
  442. return;
  443. }
  444. }
  445. # Copy with system tools
  446. my $tmp = mktemp($dest . '.XXXXXXXX');
  447. Run::runAndCheck(('cp', $file, $tmp));
  448. rename($tmp, $dest);
  449. # Return success
  450. return 1;
  451. }
  452. sub unrar($$) {
  453. my ($file, $path) = @_;
  454. if ($DEBUG) {
  455. print STDERR 'UnRARing: ' . basename($file) . "\n";
  456. }
  457. # Keep a list of old files, so we can find/delete the output
  458. my @beforeFiles = readDir($path, undef());
  459. # Run the unrar utility
  460. my $pid = fork();
  461. if (!defined($pid)) {
  462. print STDERR "Unable to fork for RAR\n";
  463. return;
  464. } elsif ($pid == 0) {
  465. # Child
  466. close(STDOUT);
  467. close(STDERR);
  468. chdir($path);
  469. my @args = ('unrar', 'e', '-p-', '-y', $file);
  470. exec { $args[0] } @args;
  471. }
  472. # Wait for the child
  473. waitpid($pid, 0);
  474. # Compare the old file list to the new one
  475. my @newFiles = ();
  476. my @afterFiles = readDir($path, undef());
  477. foreach my $file (@afterFiles) {
  478. my $found = 0;
  479. foreach my $file2 (@beforeFiles) {
  480. if ($file eq $file2) {
  481. $found = 1;
  482. last;
  483. }
  484. }
  485. if (!$found) {
  486. push(@newFiles, $file);
  487. }
  488. }
  489. # Return the list of added files
  490. return @newFiles;
  491. }
  492. sub seriesCleanup($) {
  493. my ($name) = @_;
  494. $name =~ s/\b(?:and|\&)\b/ /ig;
  495. $name =~ s/^\s*The\b//ig;
  496. if (!($name =~ /Being\W+Human/i)) {
  497. $name =~ s/\bUS\b?\s*$//ig;
  498. $name =~ s/\([^\)]*\)//g;
  499. }
  500. $name =~ s/\[[^\]]*\]//g;
  501. $name =~ s/\{[^\}]*\}//g;
  502. $name =~ s/[\(\)]//g;
  503. $name =~ s/[\'\"]//g;
  504. $name =~ s/[^\w\s]+/ /g;
  505. $name =~ s/\b20[01][0-9]\s*$//;
  506. $name =~ s/_+/ /g;
  507. $name =~ s/\s+/ /g;
  508. $name =~ s/^\s*//;
  509. $name =~ s/\s*$//;
  510. return $name;
  511. }
  512. sub readDir($$) {
  513. my ($indir, $regex) = @_;
  514. # Allow optional use of a matching regex
  515. my $useRegex = 1;
  516. if (!defined($regex) || length($regex) < 1) {
  517. $useRegex = 0;
  518. }
  519. # Clean up the input directory
  520. $indir =~ s/\/+$//;
  521. my @files = ();
  522. if (!opendir(DIR, $indir)) {
  523. warn('readDir: Unable to read directory: ' . $indir . ': ' . $! . "\n");
  524. return;
  525. }
  526. foreach my $file (readdir(DIR)) {
  527. my $keep = 0;
  528. # Regex filter (if active)
  529. if (!$useRegex) {
  530. $keep = 1;
  531. } elsif (eval('$file =~ m' . $regex)) {
  532. $keep = 1;
  533. }
  534. # Construct a complete file path
  535. $file = $indir . '/' . $file;
  536. # Ignore directories
  537. if (-d $file) {
  538. $keep = 0;
  539. }
  540. # Save matching files
  541. if ($keep) {
  542. push(@files, $file);
  543. }
  544. }
  545. closedir(DIR);
  546. # Sorts the files before returning to the caller
  547. my @orderedFiles = ();
  548. @orderedFiles = sort(@files);
  549. # Return the file list
  550. return (@orderedFiles);
  551. }