subscribe.pl 20 KB

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