subscribe.pl 16 KB

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