subscribe.pl 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965
  1. #!/usr/bin/perl
  2. use strict;
  3. use warnings;
  4. use FindBin;
  5. use lib "$FindBin::Bin/";
  6. use File::Basename;
  7. use Date::Parse;
  8. use Date::Format;
  9. use LWP::Simple;
  10. use URI::Escape;
  11. use JSON;
  12. use XML::LibXML;
  13. use IPC::System::Simple qw( system );
  14. use IPC::Cmd qw( can_run );
  15. use WWW::YouTube::Download;
  16. use PrettyPrint;
  17. # Paramters
  18. my %USERS = ('profplump' => 'kj-Ob6eYHvzo-P0UWfnQzA', 'shanda' => 'hfwMHzkPXOOFDce5hyQkTA');
  19. my $EXTRAS_FILE = 'extra_videos.ini';
  20. my $EXCLUDES_FILE = 'exclude_videos.ini';
  21. my $CURL_BIN = 'curl';
  22. my @CURL_ARGS = ('-4', '--insecure', '-H', 'Accept-Encoding: gzip', '-C', '-', '--connect-timeout', '10', '--max-time', '1800');
  23. my $BATCH_SIZE = 50;
  24. my $MAX_INDEX = 25000;
  25. my $DRIFT_TOLERANCE = 2;
  26. my $DRIFT_FACTOR = 100.0;
  27. my $DELAY = 5;
  28. my $API_URL = 'https://gdata.youtube.com/feeds/api/';
  29. my %API = (
  30. 'search' => {
  31. 'prefix' => $API_URL . 'users/',
  32. 'suffix' => '/uploads',
  33. 'params' => {
  34. 'start-index' => 1,
  35. 'max-results' => 1,
  36. 'strict' => 1,
  37. 'v' => 2,
  38. 'alt' => 'jsonc',
  39. },
  40. },
  41. 'subscriptions' => {
  42. 'prefix' => $API_URL . 'users/',
  43. 'suffix' => '/subscriptions',
  44. 'params' => {
  45. 'start-index' => 1,
  46. 'max-results' => 1,
  47. 'strict' => 1,
  48. 'v' => 2,
  49. 'alt' => 'json',
  50. },
  51. },
  52. 'video' => {
  53. 'prefix' => $API_URL . 'videos/',
  54. 'suffix' => '',
  55. 'params' => {
  56. 'strict' => 1,
  57. 'v' => 2,
  58. 'alt' => 'jsonc'
  59. },
  60. },
  61. 'channel' => {
  62. 'prefix' => $API_URL . 'users/',
  63. 'suffix' => '',
  64. 'params' => {
  65. 'strict' => 1,
  66. 'v' => 2,
  67. 'alt' => 'json'
  68. },
  69. },
  70. );
  71. # Prototypes
  72. sub findVideos($);
  73. sub findFiles();
  74. sub findVideo($);
  75. sub ytURL($);
  76. sub buildNFO($);
  77. sub buildSeriesNFO($);
  78. sub getSubscriptions($$);
  79. sub saveSubscriptions($$);
  80. sub saveChannel($);
  81. sub getChannel($);
  82. sub fetchParse($$);
  83. sub saveString($$);
  84. sub readExcludes();
  85. sub readExtras();
  86. sub parseVideoData($);
  87. sub dropExcludes($);
  88. sub addExtras($);
  89. sub updateNFOData($$$);
  90. sub videoPath($$$);
  91. sub renameVideo($$$$$);
  92. # Sanity check
  93. if (scalar(@ARGV) < 1) {
  94. die('Usage: ' . basename($0) . " output_directory\n");
  95. }
  96. # Command-line parameters
  97. my ($dir) = @ARGV;
  98. $dir =~ s/\/+$//;
  99. if (!-d $dir) {
  100. die('Invalid output directory: ' . $dir . "\n");
  101. }
  102. my $user = basename($dir);
  103. if (length($user) < 1 || !($user =~ /^\w+$/)) {
  104. die('Invalid user: ' . $user . "\n");
  105. }
  106. # Move to the target directory so we can use relative paths later
  107. chdir($dir);
  108. # Environmental parameters (debug)
  109. my $DEBUG = 0;
  110. if ($ENV{'DEBUG'}) {
  111. if ($ENV{'DEBUG'} =~ /(\d+)/) {
  112. $DEBUG = $1;
  113. } else {
  114. $DEBUG = 1;
  115. }
  116. }
  117. my $NO_FETCH = 0;
  118. if ($ENV{'NO_FETCH'}) {
  119. $NO_FETCH = 1;
  120. }
  121. my $NO_NFO = 0;
  122. if ($ENV{'NO_NFO'}) {
  123. $NO_NFO = 1;
  124. }
  125. my $NO_SEARCH = 0;
  126. if ($ENV{'NO_SEARCH'}) {
  127. $NO_SEARCH = 1;
  128. }
  129. my $NO_FILES = 0;
  130. if ($ENV{'NO_FILES'}) {
  131. $NO_FILES = 1;
  132. }
  133. my $NO_CHANNEL = 0;
  134. if ($ENV{'NO_CHANNEL'}) {
  135. $NO_CHANNEL = 1;
  136. }
  137. my $NO_EXTRAS = 0;
  138. if ($ENV{'NO_EXTRAS'}) {
  139. $NO_EXTRAS = 1;
  140. }
  141. my $NO_EXCLUDES = 0;
  142. if ($ENV{'NO_EXCLUDES'}) {
  143. $NO_EXCLUDES = 1;
  144. }
  145. # Environmental parameters (functional)
  146. my $RENAME = 0;
  147. if ($ENV{'RENAME'}) {
  148. $RENAME = 1;
  149. }
  150. my $SEASON_0 = 0;
  151. if ($ENV{'SEASON_0'}) {
  152. $SEASON_0 = 1;
  153. }
  154. my $SUDO_CHATTR = 1;
  155. if ($ENV{'NO_CHATTR'} || !can_run('sudo') || !can_run('chattr')) {
  156. $SUDO_CHATTR = 0;
  157. }
  158. if ($ENV{'MAX_INDEX'} && $ENV{'MAX_INDEX'} =~ /(\d+)/) {
  159. $MAX_INDEX = $1;
  160. }
  161. if ($ENV{'BATCH_SIZE'} && $ENV{'BATCH_SIZE'} =~ /(\d+)/) {
  162. $BATCH_SIZE = $1;
  163. }
  164. # Construct globals
  165. foreach my $key (keys(%API)) {
  166. if (exists($API{$key}{'params'}{'max-results'})) {
  167. $API{$key}{'params'}{'max-results'} = $BATCH_SIZE;
  168. }
  169. }
  170. if (!$DEBUG) {
  171. push(@CURL_ARGS, '--silent');
  172. }
  173. # Allow use as a subscription manager
  174. if ($0 =~ /subscription/i) {
  175. my %subs = ();
  176. foreach my $user (keys(%USERS)) {
  177. my $tmp = getSubscriptions($user, $USERS{$user});
  178. foreach my $sub (keys(%{$tmp})) {
  179. if (exists($subs{$sub})) {
  180. $subs{$sub} .= ', ' . $tmp->{$sub};
  181. } else {
  182. $subs{$sub} = $tmp->{$sub};
  183. }
  184. }
  185. }
  186. saveSubscriptions($dir, \%subs);
  187. exit(0);
  188. }
  189. # Grab the channel data
  190. my $channel = {};
  191. if (!$NO_CHANNEL) {
  192. $channel = getChannel($user);
  193. saveChannel($channel);
  194. }
  195. # Find all the user's videos on YT
  196. my $videos = {};
  197. if (!$NO_SEARCH) {
  198. $videos = findVideos($user);
  199. }
  200. # Find any requested "extra" videos
  201. my $extras = {};
  202. if (!$NO_EXTRAS) {
  203. $extras = addExtras($videos);
  204. }
  205. # Drop any "excludes" videos
  206. my $excludes = {};
  207. if (!$NO_EXCLUDES) {
  208. $excludes = dropExcludes($videos);
  209. }
  210. # Calculate the episode number using the publish dates
  211. {
  212. my @byDate = sort { $videos->{$a}->{'date'} <=> $videos->{$b}->{'date'} || $a cmp $b } keys %{$videos};
  213. my $num = undef();
  214. my $lastSeason = undef();
  215. foreach my $id (@byDate) {
  216. if (!$lastSeason || $lastSeason != $videos->{$id}->{'season'}) {
  217. $num = 1;
  218. $lastSeason = $videos->{$id}->{'season'};
  219. }
  220. $videos->{$id}->{'number'} = $num++;
  221. }
  222. }
  223. # Find all existing YT files on disk
  224. my $files = {};
  225. if (!$NO_FILES) {
  226. $files = findFiles();
  227. }
  228. # Whine about unknown videos
  229. foreach my $id (keys(%{$files})) {
  230. if (!exists($videos->{$id}) && $files->{$id}->{'season'} > 0) {
  231. print STDERR 'Local video not known to YT channel (' . $user . '): ' . $id . "\n";
  232. if ($SEASON_0) {
  233. renameVideo($files->{$id}->{'path'}, $files->{$id}->{'suffix'}, $id, 0, $files->{$id}->{'season'} . $files->{$id}->{'number'});
  234. }
  235. }
  236. }
  237. # Fill in missing videos and NFOs
  238. foreach my $id (keys(%{$videos})) {
  239. my $basePath = videoPath($videos->{$id}->{'season'}, $videos->{$id}->{'number'}, $id);
  240. my $nfo = $basePath . 'nfo';
  241. # Warn (and optionally rename) if the video numbers drift
  242. if (
  243. exists($files->{$id})
  244. && ( $files->{$id}->{'number'} != $videos->{$id}->{'number'}
  245. || $files->{$id}->{'season'} != $videos->{$id}->{'season'})
  246. )
  247. {
  248. if ($RENAME) {
  249. renameVideo($files->{$id}->{'path'}, $files->{$id}->{'suffix'}, $id, $videos->{$id}->{'season'}, $videos->{$id}->{'number'});
  250. } else {
  251. # Find the old NFO to avoid re-fetching
  252. $nfo = $files->{$id}->{'nfo'};
  253. # Ignore small changes
  254. my $delta = abs($files->{$id}->{'number'} - $videos->{$id}->{'number'});
  255. my $tolerance = $files->{$id}->{'number'} / $DRIFT_FACTOR;
  256. if ($tolerance < $DRIFT_TOLERANCE) {
  257. $tolerance = $DRIFT_TOLERANCE;
  258. }
  259. if ($delta > $tolerance || $DEBUG) {
  260. print STDERR 'Video ' . $id . ' had video number ' . $files->{$id}->{'number'} . ' but now has video number ' . $videos->{$id}->{'number'} . "\n";
  261. }
  262. }
  263. }
  264. # If we haven't heard of the file, or don't have an NFO for it
  265. # Checking for the NFO allows use to resume failed downloads
  266. if (!exists($files->{$id}) || !-e $nfo) {
  267. if ($DEBUG) {
  268. print STDERR 'Fetching video: ' . $id . "\n";
  269. }
  270. # Find the download URL and file suffix
  271. my ($url, $suffix) = ytURL($id);
  272. if (!defined($url) || length($url) < 5) {
  273. warn('Could not determine URL for video: ' . $id . "\n");
  274. $url = undef();
  275. }
  276. # Fetch with cURL
  277. # I know LWP exists (and is even loaded) but cURL makes my life easier
  278. if ($url) {
  279. my @cmd = ($CURL_BIN);
  280. push(@cmd, @CURL_ARGS);
  281. push(@cmd, '-o', $basePath . $suffix, $url);
  282. if ($NO_FETCH) {
  283. print STDERR 'Not running: ' . join(' ', @cmd) . "\n";
  284. } else {
  285. if ($DEBUG > 1) {
  286. print STDERR join(' ', @cmd) . "\n";
  287. }
  288. sleep($DELAY);
  289. system(@cmd);
  290. }
  291. }
  292. # Build and save the XML document
  293. my $xml = buildNFO($videos->{$id});
  294. if ($DEBUG > 1) {
  295. print STDERR 'Saving NFO: ' . $xml . "\n";
  296. }
  297. if ($NO_NFO) {
  298. print STDERR "Not saving NFO\n";
  299. } else {
  300. saveString($nfo, $xml);
  301. }
  302. }
  303. }
  304. sub saveString($$) {
  305. my ($path, $str) = @_;
  306. my $fh = undef();
  307. if (!open($fh, '>', $path)) {
  308. warn('Cannot open file for writing: ' . $path . ': ' . $! . "\n");
  309. return undef();
  310. }
  311. print $fh $str;
  312. close($fh);
  313. return 1;
  314. }
  315. sub buildSeriesNFO($) {
  316. my ($channel) = @_;
  317. # Create an XML tree
  318. my $doc = XML::LibXML::Document->new('1.0', 'UTF-8');
  319. $doc->setStandalone(1);
  320. my $show = $doc->createElement('tvshow');
  321. $doc->setDocumentElement($show);
  322. my $elm;
  323. # Add data
  324. $elm = $doc->createElement('title');
  325. $elm->appendText($channel->{'title'});
  326. $show->appendChild($elm);
  327. $elm = $doc->createElement('premiered');
  328. $elm->appendText(time2str('%Y-%m-%d', $channel->{'date'}));
  329. $show->appendChild($elm);
  330. $elm = $doc->createElement('plot');
  331. $elm->appendText($channel->{'description'});
  332. $show->appendChild($elm);
  333. # Return the string
  334. return $doc->toString();
  335. }
  336. sub buildNFO($) {
  337. my ($video) = @_;
  338. # Create an XML tree
  339. my $doc = XML::LibXML::Document->new('1.0', 'UTF-8');
  340. $doc->setStandalone(1);
  341. my $show = $doc->createElement('episodedetails');
  342. $doc->setDocumentElement($show);
  343. my $elm;
  344. # Add data
  345. $elm = $doc->createElement('season');
  346. $elm->appendText($video->{'season'});
  347. $show->appendChild($elm);
  348. $elm = $doc->createElement('episode');
  349. $elm->appendText($video->{'number'});
  350. $show->appendChild($elm);
  351. if (!defined($video->{'title'})) {
  352. $video->{'title'} = 'Episode ' . $video->{'number'};
  353. }
  354. $elm = $doc->createElement('title');
  355. $elm->appendText($video->{'title'});
  356. $show->appendChild($elm);
  357. if (!defined($video->{'date'})) {
  358. $video->{'date'} = time();
  359. }
  360. $elm = $doc->createElement('aired');
  361. $elm->appendText(time2str('%Y-%m-%d', $video->{'date'}));
  362. $show->appendChild($elm);
  363. if (defined($video->{'description'})) {
  364. $elm = $doc->createElement('plot');
  365. $elm->appendText($video->{'description'});
  366. $show->appendChild($elm);
  367. }
  368. if (defined($video->{'duration'})) {
  369. $elm = $doc->createElement('runtime');
  370. $elm->appendText($video->{'duration'});
  371. $show->appendChild($elm);
  372. }
  373. if (defined($video->{'rating'})) {
  374. $elm = $doc->createElement('rating');
  375. $elm->appendText($video->{'rating'});
  376. $show->appendChild($elm);
  377. }
  378. if (defined($video->{'creator'})) {
  379. $elm = $doc->createElement('director');
  380. $elm->appendText($video->{'creator'});
  381. $show->appendChild($elm);
  382. }
  383. # Return the string
  384. return $doc->toString();
  385. }
  386. sub ytURL($) {
  387. my ($id) = @_;
  388. # Init the YT object
  389. my $tube = WWW::YouTube::Download->new;
  390. # Fetch metadata
  391. my $meta = eval { $tube->prepare_download($id); };
  392. if (!defined($meta) || ref($meta) ne 'HASH' || !exists($meta->{'video_url_map'}) || ref($meta->{'video_url_map'}) ne 'HASH') {
  393. if ($@ =~ /age gate/i) {
  394. print STDERR "Video not available due to age gate restrictions\n";
  395. }
  396. return undef();
  397. }
  398. # Find the best stream (i.e. highest resolution, prefer mp4)
  399. my $bestStream = undef();
  400. my $bestResolution = 0;
  401. foreach my $streamID (keys(%{ $meta->{'video_url_map'} })) {
  402. my $stream = $meta->{'video_url_map'}->{$streamID};
  403. my ($res) = $stream->{'resolution'} =~ /^\s*(\d+)/;
  404. if ($DEBUG > 1) {
  405. print STDERR $streamID . ' (' . $stream->{'suffix'} . ')' . ' => ' . $stream->{'resolution'} . ' : ' . $stream->{'url'} . "\n";
  406. }
  407. if ( ($res > $bestResolution)
  408. || ($res == $bestResolution && defined($bestStream) && $bestStream->{'suffix'} ne 'mp4'))
  409. {
  410. $bestStream = $stream;
  411. $bestResolution = $res;
  412. }
  413. }
  414. if (!exists($bestStream->{'suffix'}) || length($bestStream->{'suffix'}) < 2) {
  415. $bestStream->{'suffix'} = 'mp4';
  416. }
  417. return ($bestStream->{'url'}, $bestStream->{'suffix'});
  418. }
  419. sub findFiles() {
  420. my %files = ();
  421. # Allow complete bypass
  422. if ($NO_FILES) {
  423. return \%files;
  424. }
  425. # Read the output directory
  426. my $fh = undef();
  427. opendir($fh, '.')
  428. or die('Unable to open files directory: ' . $! . "\n");
  429. while (my $file = readdir($fh)) {
  430. my ($season, $num, $id, $suffix) = $file =~ /^S(\d+)+E(\d+) - ([\w\-]+)\.(\w\w\w)$/i;
  431. if (defined($id) && length($id) > 0) {
  432. if ($suffix eq 'nfo') {
  433. next;
  434. }
  435. my %tmp = (
  436. 'season' => $season,
  437. 'number' => $num,
  438. 'suffix' => $suffix,
  439. 'path' => $file,
  440. );
  441. $tmp{'nfo'} = $tmp{'path'};
  442. $tmp{'nfo'} =~ s/\.\w\w\w$/\.nfo/;
  443. if (exists($files{$id})) {
  444. warn('Duplicate ID: ' . $id . "\n\t" . $files{$id}->{'path'} . "\n\t" . $tmp{'path'} . "\n");
  445. if ($RENAME) {
  446. # Prefer to delete MP4 files, if the suffixes differ
  447. my $del = $files{$id};
  448. if ($tmp{'suffix'} eq 'mp4' && $del->{'suffix'} ne 'mp4') {
  449. $del = \%tmp;
  450. %tmp = %{ $files{$id} };
  451. }
  452. print STDERR 'Deleting duplicate: ' . $del->{'path'} . "\n";
  453. unlink($del->{'path'});
  454. unlink($del->{'nfo'});
  455. }
  456. }
  457. $files{$id} = \%tmp;
  458. }
  459. }
  460. close($fh);
  461. if ($DEBUG) {
  462. print STDERR 'Found ' . scalar(keys(%files)) . " local videos\n";
  463. if ($DEBUG > 1) {
  464. print STDERR prettyPrint(\%files, "\t") . "\n";
  465. }
  466. }
  467. return \%files;
  468. }
  469. sub fetchParse($$) {
  470. my ($name, $id) = @_;
  471. my $url = $API{$name}{'prefix'} . $id . $API{$name}{'suffix'} . '?';
  472. foreach my $key (keys(%{ $API{$name}{'params'} })) {
  473. $url .= '&' . uri_escape($key) . '=' . uri_escape($API{$name}{'params'}{$key});
  474. }
  475. # Fetch
  476. if ($DEBUG) {
  477. print STDERR 'Fetching ' . $name . ' API URL: ' . $url . "\n";
  478. }
  479. sleep($DELAY);
  480. my $content = get($url);
  481. if (!defined($content) || length($content) < 10) {
  482. die('Invalid content from URL: ' . $url . "\n");
  483. }
  484. # Parse
  485. my $data = decode_json($content);
  486. if (!defined($data) || ref($data) ne 'HASH') {
  487. die('Invalid JSON: ' . $content . "\n");
  488. }
  489. if ($DEBUG > 2) {
  490. print STDERR "Raw JSON data:\n" . prettyPrint($data, ' ') . "\n";
  491. }
  492. return $data;
  493. }
  494. sub getSubscriptions($$) {
  495. my ($user, $id) = @_;
  496. my $index = 1;
  497. my $itemCount = undef();
  498. my %subs = ();
  499. SUBS_LOOP:
  500. {
  501. # Build, fetch, parse
  502. $API{'subscriptions'}{'params'}{'start-index'} = $index;
  503. my $data = fetchParse('subscriptions', $id);
  504. # It's all in the feed
  505. if (!exists($data->{'feed'}) || ref($data->{'feed'}) ne 'HASH') {
  506. die("Invalid subscription data\n");
  507. }
  508. $data = $data->{'feed'};
  509. # Grab the total count, so we know when to stop
  510. if (!defined($itemCount)) {
  511. if ( !exists($data->{'openSearch$totalResults'})
  512. || ref($data->{'openSearch$totalResults'}) ne 'HASH'
  513. || !exists($data->{'openSearch$totalResults'}->{'$t'}))
  514. {
  515. die("Invalid subscription feed metadata\n");
  516. }
  517. $itemCount = $data->{'openSearch$totalResults'}->{'$t'};
  518. }
  519. # Process each item
  520. if (!exists($data->{'entry'}) || ref($data->{'entry'}) ne 'ARRAY') {
  521. die("Invalid subscription entries\n");
  522. }
  523. my $items = $data->{'entry'};
  524. foreach my $item (@{$items}) {
  525. if ( ref($item) ne 'HASH'
  526. || !exists($item->{'yt$username'})
  527. || ref($item->{'yt$username'}) ne 'HASH'
  528. || !exists($item->{'yt$username'}->{'$t'}))
  529. {
  530. next;
  531. }
  532. if ($DEBUG) {
  533. print STDERR prettyPrint($item->{'yt$username'}, "\t") . "\n";
  534. }
  535. $subs{ $item->{'yt$username'}->{'$t'} } = $user;
  536. }
  537. # Loop if there are results left to fetch
  538. $index += $BATCH_SIZE;
  539. if (defined($itemCount) && $itemCount >= $index) {
  540. # But don't go past the max supported index
  541. if ($index <= $MAX_INDEX) {
  542. redo SUBS_LOOP;
  543. }
  544. }
  545. }
  546. # Return the list of subscribed usernames
  547. return \%subs;
  548. }
  549. sub saveSubscriptions($$) {
  550. my ($folder, $subs) = @_;
  551. # Check for local subscriptions missing from YT
  552. my %locals = ();
  553. my $fh = undef();
  554. opendir($fh, '.')
  555. or die('Unable to open subscriptions directory: ' . $folder . ': ' . $! . "\n");
  556. while (my $file = readdir($fh)) {
  557. # Skip dotfiles
  558. if ($file =~ /^\./) {
  559. next;
  560. }
  561. # Skip non-directories
  562. if (!-d $file) {
  563. next;
  564. }
  565. # YT has some trouble with case
  566. my $lcFile = lc($file);
  567. # Anything else should be in the list
  568. if (!$subs->{$file} && !$subs->{$lcFile}) {
  569. print STDERR 'Missing YT subscription for: ' . $file . "\n";
  570. }
  571. # Note local subscriptions
  572. $locals{$lcFile} = 1;
  573. }
  574. closedir($fh);
  575. # Check for YT subscriptions missing locally
  576. foreach my $sub (keys(%{$subs})) {
  577. if (!exists($locals{ lc($sub) })) {
  578. print STDERR 'Adding local subscription for: ' . $sub . ' (' . $subs->{$sub} . ")\n";
  579. mkdir($folder . '/' . $sub);
  580. }
  581. }
  582. }
  583. sub saveChannel($) {
  584. my ($channel) = @_;
  585. my $nfo = 'tvshow.nfo';
  586. if (!-e $nfo) {
  587. if ($DEBUG) {
  588. print STDERR 'Saving series data for: ' . $channel->{'title'} . "\n";
  589. }
  590. # Save the poster
  591. if (exists($channel->{'thumbnail'}) && length($channel->{'thumbnail'}) > 5) {
  592. my $jpg = get($channel->{'thumbnail'});
  593. saveString('poster.jpg', $jpg);
  594. }
  595. # Save the series NFO
  596. my $xml = buildSeriesNFO($channel);
  597. if ($DEBUG > 1) {
  598. print STDERR 'Saving NFO: ' . $xml . "\n";
  599. }
  600. saveString($nfo, $xml);
  601. }
  602. }
  603. sub getChannel($) {
  604. my ($user) = @_;
  605. # Build, fetch, parse
  606. my $data = fetchParse('channel', $user);
  607. if (!exists($data->{'entry'}) || ref($data->{'entry'}) ne 'HASH') {
  608. die("Invalid channel data\n");
  609. }
  610. $data = $data->{'entry'};
  611. # Extract the data we want
  612. my %channel = (
  613. 'id' => $data->{'yt$channelId'}->{'$t'},
  614. 'title' => $data->{'title'}->{'$t'},
  615. 'date' => str2time($data->{'published'}->{'$t'}),
  616. 'description' => $data->{'summary'}->{'$t'},
  617. 'thumbnail' => $data->{'media$thumbnail'}->{'url'},
  618. );
  619. return \%channel;
  620. }
  621. sub readExcludes() {
  622. my %excludes = ();
  623. # Read and parse the excludes videos file, if it exists
  624. if (-e $EXCLUDES_FILE) {
  625. my $fh;
  626. open($fh, $EXCLUDES_FILE)
  627. or die('Unable to open excludes videos file: ' . $! . "\n");
  628. while (<$fh>) {
  629. # Skip blank lines and comments
  630. if ($_ =~ /^\s*#/ || $_ =~ /^\s*$/) {
  631. next;
  632. }
  633. # Match our specific format or whine
  634. if ($_ =~ /^\s*([\w\-]+)\s*$/) {
  635. if ($DEBUG > 1) {
  636. print STDERR 'Adding exclude video: ' . $1 . "\n";
  637. }
  638. $excludes{$1} = 1;
  639. } else {
  640. print STDERR 'Skipped exclude video line: ' . $_;
  641. }
  642. }
  643. close($fh);
  644. }
  645. return \%excludes;
  646. }
  647. sub readExtras() {
  648. my %extras = ();
  649. # Read and parse the extra videos file, if it exists
  650. if (-e $EXTRAS_FILE) {
  651. my $fh;
  652. open($fh, $EXTRAS_FILE)
  653. or die('Unable to open extra videos file: ' . $! . "\n");
  654. while (<$fh>) {
  655. # Skip blank lines and comments
  656. if ($_ =~ /^\s*#/ || $_ =~ /^\s*$/) {
  657. next;
  658. }
  659. # Match our specific format or whine
  660. if ($_ =~ /^\s*(\d+)\s*[=:>]+\s*([\w\-]+)\s*$/) {
  661. if ($DEBUG > 1) {
  662. print STDERR 'Adding extra video: ' . $1 . ' => ' . $2 . "\n";
  663. }
  664. $extras{$2} = $1;
  665. } else {
  666. print STDERR 'Skipped extra video line: ' . $_;
  667. }
  668. }
  669. close($fh);
  670. }
  671. return \%extras;
  672. }
  673. sub parseVideoData($) {
  674. my ($data) = @_;
  675. my %video = (
  676. 'title' => $data->{'title'},
  677. 'date' => str2time($data->{'uploaded'}),
  678. 'description' => $data->{'description'},
  679. 'duration' => $data->{'duration'},
  680. 'rating' => $data->{'rating'},
  681. 'creator' => $data->{'uploader'},
  682. );
  683. $video{'season'} = time2str('%Y', $video{'date'});
  684. return \%video;
  685. }
  686. sub findVideo($) {
  687. my ($id) = @_;
  688. # Build, fetch, parse
  689. my $data = fetchParse('video', $id);
  690. # Validate
  691. if (!exists($data->{'data'})) {
  692. die("Invalid video list\n");
  693. }
  694. $data = $data->{'data'};
  695. # Parse the video data
  696. return parseVideoData($data);
  697. }
  698. sub dropExcludes($) {
  699. my ($videos) = @_;
  700. my $excludes = readExcludes();
  701. foreach my $id (keys(%{$excludes})) {
  702. if (!exists($videos->{$id})) {
  703. if ($DEBUG > 1) {
  704. print STDERR 'Skipping unknown excludes video: ' . $id . "\n";
  705. }
  706. next;
  707. }
  708. delete($videos->{$id});
  709. }
  710. return $excludes;
  711. }
  712. sub addExtras($) {
  713. my ($videos) = @_;
  714. my $extras = readExtras();
  715. foreach my $id (keys(%{$extras})) {
  716. if (exists($videos->{$id})) {
  717. if ($DEBUG > 1) {
  718. print STDERR 'Skipping known extra video: ' . $id . "\n";
  719. }
  720. next;
  721. }
  722. my $video = findVideo($id);
  723. $videos->{$id} = $video;
  724. }
  725. return $extras;
  726. }
  727. sub findVideos($) {
  728. my ($user) = @_;
  729. my %videos = ();
  730. # Allow complete bypass
  731. if ($NO_SEARCH) {
  732. return \%videos;
  733. }
  734. # Loop through until we have all the entries
  735. my $index = 1;
  736. my $itemCount = undef();
  737. LOOP:
  738. {
  739. # Build, fetch, parse
  740. $API{'search'}{'params'}{'start-index'} = $index;
  741. my $data = fetchParse('search', $user);
  742. # Grab the total count, so we know when to stop
  743. if (!exists($data->{'data'})) {
  744. die("Invalid video list\n");
  745. }
  746. $data = $data->{'data'};
  747. if (!defined($itemCount) && exists($data->{'totalItems'})) {
  748. $itemCount = $data->{'totalItems'};
  749. }
  750. # Process each item
  751. if (!exists($data->{'items'}) || ref($data->{'items'}) ne 'ARRAY') {
  752. die("Invalid video list\n");
  753. }
  754. my $items = $data->{'items'};
  755. my $offset = 0;
  756. foreach my $item (@{$items}) {
  757. my $video = parseVideoData($item);
  758. $videos{ $item->{'id'} } = $video;
  759. $offset++;
  760. }
  761. # Loop if there are results left to fetch
  762. $index += $BATCH_SIZE;
  763. if (defined($itemCount) && $itemCount >= $index) {
  764. # But don't go past the max supported index
  765. if ($index <= $MAX_INDEX) {
  766. redo LOOP;
  767. }
  768. }
  769. }
  770. if ($DEBUG) {
  771. print STDERR 'Found ' . scalar(keys(%videos)) . " remote videos\n";
  772. if ($DEBUG > 1) {
  773. print STDERR prettyPrint(\%videos, "\t") . "\n";
  774. }
  775. }
  776. return \%videos;
  777. }
  778. sub updateNFOData($$$) {
  779. my ($file, $season, $episode) = @_;
  780. my $nfoData = undef();
  781. {
  782. open(my $fh, '<', $file)
  783. or warn('Unable to open NFO for renumber: ' . $file . ': ' . $! . "\n");
  784. $/ = undef;
  785. $nfoData = <$fh>;
  786. close($fh);
  787. }
  788. if ($nfoData) {
  789. $nfoData =~ s/<season>\d+<\/season>/<season>${season}<\/season>/;
  790. $nfoData =~ s/<episode>\d+<\/episode>/<episode>${episode}<\/episode>/;
  791. }
  792. return $nfoData;
  793. }
  794. sub videoPath($$$) {
  795. my ($season, $episode, $id) = @_;
  796. return sprintf('S%02dE%02d - %s.', $season, $episode, $id);
  797. }
  798. sub renameVideo($$$$$) {
  799. my ($video, $suffix, $id, $season, $episode) = @_;
  800. # General sanity checks
  801. if (!defined($video) || !defined($suffix) || !defined($id) || !defined($season) || !defined($episode)) {
  802. die("Invalid call to renameVideo()\n\t" . $video . "\n\t" . $suffix . "\n\t" . $id . "\n\t" . $season . "\n\t" . $episode . "\n");
  803. }
  804. # We could ask for this but there are already a lot of parameters
  805. my $nfo = $video;
  806. $nfo =~ s/\.${suffix}$/\.nfo/;
  807. # Build a new path
  808. my $base = videoPath($season, $episode, $id);
  809. my $videoNew = $base . $suffix;
  810. my $nfoNew = $base . 'nfo';
  811. print STDERR 'Renaming ' . $video . ' => ' . $videoNew . "\n";
  812. # Parse old NFO data
  813. my $nfoData = updateNFOData($nfo, $season, $episode)
  814. or die('No NFO data found, refusing to rename: ' . $id . "\n");
  815. # Specific sanity checks, as we do dangerous work here
  816. if (!-r $video || !-r $nfo) {
  817. die('Invalid video or NFO sources in rename: ' . $video . '/' . $nfo);
  818. }
  819. if (-e $videoNew) {
  820. die('Rename: Target exists: ' . $videoNew . "\n");
  821. }
  822. # Rename video
  823. if ($SUDO_CHATTR) {
  824. system('sudo', 'chattr', '-i', $video);
  825. }
  826. rename($video, $videoNew)
  827. or die('Unable to rename: ' . $video . ': ' . $! . "\n");
  828. # Write a new NFO and unlink the old one
  829. saveString($nfoNew, $nfoData);
  830. if ($SUDO_CHATTR) {
  831. system('sudo', 'chattr', '-i', $nfo);
  832. }
  833. unlink($nfo)
  834. or warn('Unable to delete NFO during rename: ' . $! . "\n");
  835. }