ytSubscribe.pl 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. #!/usr/bin/perl
  2. use strict;
  3. use warnings;
  4. use File::Basename;
  5. use Date::Parse;
  6. use Date::Format;
  7. use LWP::Simple;
  8. use URI::Escape;
  9. use JSON;
  10. use XML::LibXML;
  11. use IPC::System::Simple qw( system );
  12. use WWW::YouTube::Download;
  13. use PrettyPrint;
  14. # Paramters
  15. my $CURL_BIN = 'curl';
  16. my @CURL_ARGS = ('-4', '--insecure', '-C', '-');
  17. my $BATCH_SIZE = 50;
  18. my $MAX_INDEX = 500;
  19. my $URL_PREFIX = 'http://gdata.youtube.com/feeds/api/users/';
  20. my $URL_SUFFIX = '/uploads';
  21. my %CHAN_PARAMS = (
  22. 'strict' => 1,
  23. 'v' => 2,
  24. 'alt' => 'json',
  25. );
  26. my %LIST_PARAMS = (
  27. 'strict' => 1,
  28. 'v' => 2,
  29. 'alt' => 'jsonc',
  30. 'orderby' => 'published',
  31. );
  32. # Prototypes
  33. sub findVideos($);
  34. sub findFiles($);
  35. sub ytURL($);
  36. sub buildNFO($);
  37. sub buildSeriesNFO($);
  38. sub getChannel($);
  39. sub fetchParse($);
  40. sub buildURL($$);
  41. sub saveString($$);
  42. # Sanity check
  43. if (scalar(@ARGV) < 1) {
  44. die('Usage: ' . basename($0) . " output_directory\n");
  45. }
  46. # Command-line parameters
  47. my ($dir) = @ARGV;
  48. $dir =~ s/\/+$//;
  49. if (!-d $dir) {
  50. die('Invalid output directory: ' . $dir . "\n");
  51. }
  52. my $user = basename($dir);
  53. if (length($user) < 1 || !($user =~ /^\w+$/)) {
  54. die('Invalid user: ' . $user . "\n");
  55. }
  56. # Environmental parameters
  57. my $DEBUG = 0;
  58. if ($ENV{'DEBUG'}) {
  59. if ($ENV{'DEBUG'} =~ /(\d+)/) {
  60. $DEBUG = $1;
  61. } else {
  62. $DEBUG = 1;
  63. }
  64. }
  65. my $NO_FETCH = 0;
  66. if ($ENV{'NO_FETCH'}) {
  67. $NO_FETCH = 1;
  68. }
  69. if ($ENV{'MAX_INDEX'} && $ENV{'MAX_INDEX'} =~ /(\d+)/) {
  70. $MAX_INDEX = $1;
  71. }
  72. if ($ENV{'BATCH_SIZE'} && $ENV{'BATCH_SIZE'} =~ /(\d+)/) {
  73. $BATCH_SIZE = $1;
  74. }
  75. # Construct globals
  76. $LIST_PARAMS{'max-results'} = $BATCH_SIZE;
  77. if (!$DEBUG) {
  78. push(@CURL_ARGS, '--silent');
  79. }
  80. # Grab the channel data
  81. my $channel = getChannel($user);
  82. # Create the channel NFO and poster, if needed
  83. my $nfo = $dir . '/tvshow.nfo';
  84. if (!-e $nfo) {
  85. if ($DEBUG) {
  86. print STDERR 'Saving series data for: ' . $channel->{'title'} . "\n";
  87. }
  88. # Save the poster
  89. if (exists($channel->{'thumbnail'}) && length($channel->{'thumbnail'}) > 5) {
  90. my $poster = $dir . '/poster.jpg';
  91. my $jpg = get($channel->{'thumbnail'});
  92. saveString($poster, $jpg);
  93. }
  94. # Save the series NFO
  95. my $xml = buildSeriesNFO($channel);
  96. if ($DEBUG > 1) {
  97. print STDERR 'Saving NFO: ' . $xml . "\n";
  98. }
  99. saveString($nfo, $xml);
  100. }
  101. # Find all the user's videos on YT
  102. my $videos = findVideos($user);
  103. # Find all existing YT files on disk
  104. my $files = findFiles($dir);
  105. # Fill in missing videos and NFOs
  106. foreach my $id (keys(%{$videos})) {
  107. my $basePath = $dir . '/' . sprintf('%02d', $videos->{$id}->{'number'}) . ' - ' . $id . '.';
  108. $nfo = $basePath . 'nfo';
  109. # If we haven't heard of the file, or don't have an NFO for it
  110. # Checking for the NFO allows use to resume failed downloads
  111. if (!exists($files->{$id}) || !-e $nfo) {
  112. if ($DEBUG) {
  113. print STDERR 'Fetching video: ' . $id . "\n";
  114. }
  115. # Find the download URL and file suffix
  116. my ($url, $suffix) = ytURL($id);
  117. if (!defined($url) || length($url) < 5) {
  118. warn('Could not determine URL for video: ' . $id . "\n");
  119. next;
  120. }
  121. # Fetch with cURL
  122. # I know LWP exists (and is even loaded) but cURL makes my life easier
  123. {
  124. my @cmd = ($CURL_BIN);
  125. push(@cmd, @CURL_ARGS);
  126. push(@cmd, '-o', $basePath . $suffix, $url);
  127. if ($NO_FETCH) {
  128. print STDERR 'Not fetching: ' . join(' ', @cmd) . "\n";
  129. } else {
  130. if ($DEBUG > 1) {
  131. print STDERR join(' ', @cmd) . "\n";
  132. }
  133. system(@cmd);
  134. }
  135. }
  136. # Build and save the XML document
  137. my $xml = buildNFO($videos->{$id});
  138. if ($DEBUG > 1) {
  139. print STDERR 'Saving NFO: ' . $xml . "\n";
  140. }
  141. saveString($nfo, $xml);
  142. }
  143. }
  144. sub saveString($$) {
  145. my ($path, $str) = @_;
  146. my $fh = undef();
  147. if (!open($fh, '>', $path)) {
  148. warn('Cannot open file for writing: ' . $! . "\n");
  149. return undef();
  150. }
  151. print $fh $str;
  152. close($fh);
  153. return 1;
  154. }
  155. sub buildSeriesNFO($) {
  156. my ($channel) = @_;
  157. # Create an XML tree
  158. my $doc = XML::LibXML::Document->new('1.0', 'UTF-8');
  159. $doc->setStandalone(1);
  160. my $show = $doc->createElement('tvshow');
  161. $doc->setDocumentElement($show);
  162. my $elm;
  163. # Add data
  164. $elm = $doc->createElement('title');
  165. $elm->appendText($channel->{'title'});
  166. $show->appendChild($elm);
  167. $elm = $doc->createElement('premiered');
  168. $elm->appendText(time2str('%Y-%m-%d', $channel->{'date'}));
  169. $show->appendChild($elm);
  170. $elm = $doc->createElement('plot');
  171. $elm->appendText($channel->{'description'});
  172. $show->appendChild($elm);
  173. # Return the string
  174. return $doc->toString();
  175. }
  176. sub buildNFO($) {
  177. my ($video) = @_;
  178. # Create an XML tree
  179. my $doc = XML::LibXML::Document->new('1.0', 'UTF-8');
  180. $doc->setStandalone(1);
  181. my $show = $doc->createElement('episodedetails');
  182. $doc->setDocumentElement($show);
  183. my $elm;
  184. # Add data
  185. $elm = $doc->createElement('season');
  186. $elm->appendText('1');
  187. $show->appendChild($elm);
  188. $elm = $doc->createElement('episode');
  189. $elm->appendText($video->{'number'});
  190. $show->appendChild($elm);
  191. $elm = $doc->createElement('title');
  192. $elm->appendText($video->{'title'});
  193. $show->appendChild($elm);
  194. $elm = $doc->createElement('aired');
  195. $elm->appendText(time2str('%Y-%m-%d', $video->{'date'}));
  196. $show->appendChild($elm);
  197. $elm = $doc->createElement('plot');
  198. $elm->appendText($video->{'description'});
  199. $show->appendChild($elm);
  200. $elm = $doc->createElement('runtime');
  201. $elm->appendText($video->{'duration'});
  202. $show->appendChild($elm);
  203. $elm = $doc->createElement('rating');
  204. $elm->appendText($video->{'rating'});
  205. $show->appendChild($elm);
  206. $elm = $doc->createElement('director');
  207. $elm->appendText($video->{'creator'});
  208. $show->appendChild($elm);
  209. # Return the string
  210. return $doc->toString();
  211. }
  212. sub ytURL($) {
  213. my ($id) = @_;
  214. # Init the YT object
  215. my $tube = WWW::YouTube::Download->new;
  216. # Fetch metadata
  217. my $meta = $tube->prepare_download($id);
  218. if (!defined($meta) || ref($meta) ne 'HASH' || !exists($meta->{'video_url_map'}) || ref($meta->{'video_url_map'}) ne 'HASH') {
  219. return undef();
  220. }
  221. # Find the best stream (i.e. highest resolution, prefer mp4)
  222. my $bestStream = undef();
  223. my $bestResolution = 0;
  224. foreach my $streamID (keys(%{ $meta->{'video_url_map'} })) {
  225. my $stream = $meta->{'video_url_map'}->{$streamID};
  226. my ($res) = $stream->{'resolution'} =~ /^\s*(\d+)/;
  227. if ($DEBUG > 1) {
  228. print STDERR $streamID . ' (' . $stream->{'suffix'} . ')' . ' => ' . $stream->{'resolution'} . ' : ' . $stream->{'url'} . "\n";
  229. }
  230. if ( ($res > $bestResolution)
  231. || ($res == $bestResolution && defined($bestStream) && $bestStream->{'suffix'} ne 'mp4'))
  232. {
  233. $bestStream = $stream;
  234. $bestResolution = $res;
  235. }
  236. }
  237. if (!exists($bestStream->{'suffix'}) || length($bestStream->{'suffix'}) < 2) {
  238. $bestStream->{'suffix'} = 'mp4';
  239. }
  240. return ($bestStream->{'url'}, $bestStream->{'suffix'});
  241. }
  242. sub findFiles($) {
  243. my ($dir) = @_;
  244. my %files = ();
  245. # Read the output directory
  246. my $fh = undef();
  247. opendir($fh, $dir)
  248. or die('Unable to open output directory: ' . $! . "\n");
  249. while (my $file = readdir($fh)) {
  250. my ($num, $id, $suffix) = $file =~ /(\d+) - ([\w\-]+)\.(\w\w\w)$/;
  251. if (defined($id) && length($id) > 0) {
  252. if ($suffix eq 'nfo') {
  253. next;
  254. }
  255. my %tmp = (
  256. 'number' => $num,
  257. 'suffix' => $suffix,
  258. 'path' => $dir . '/' . $file,
  259. );
  260. $tmp{'nfo'} = $tmp{'path'};
  261. $tmp{'nfo'} =~ s/\.\w\w\w$/\.nfo/;
  262. $files{$id} = \%tmp;
  263. }
  264. }
  265. close($fh);
  266. if ($DEBUG) {
  267. print STDERR "\nFound " . scalar(keys(%files)) . " files:\n\t" . join("\n\t", keys(%files)) . "\n";
  268. if ($DEBUG > 1) {
  269. print STDERR prettyPrint(\%files, "\t") . "\n";
  270. }
  271. }
  272. return \%files;
  273. }
  274. sub buildURL($$) {
  275. my ($base, $params) = @_;
  276. my $url = $base . '?';
  277. foreach my $name (keys(%{$params})) {
  278. $url .= '&' . uri_escape($name) . '=' . uri_escape($params->{$name});
  279. }
  280. return $url;
  281. }
  282. sub fetchParse($) {
  283. my ($url) = @_;
  284. # Fetch
  285. if ($DEBUG) {
  286. print STDERR 'Fetching list URL: ' . $url . "\n";
  287. }
  288. my $content = get($url);
  289. if (!defined($content) || length($content) < 10) {
  290. die('Invalid content from URL: ' . $url . "\n");
  291. }
  292. # Parse
  293. my $data = decode_json($content);
  294. if (!defined($data) || ref($data) ne 'HASH') {
  295. die('Invalid JSON: ' . $content . "\n");
  296. }
  297. if ($DEBUG > 2) {
  298. print STDERR "Raw JSON data:\n" . prettyPrint($data, ' ') . "\n";
  299. }
  300. return $data;
  301. }
  302. sub getChannel($) {
  303. my ($user) = @_;
  304. # Build, fetch, parse
  305. my $url = buildURL($URL_PREFIX . $user, \%CHAN_PARAMS);
  306. my $data = fetchParse($url);
  307. if (!exists($data->{'entry'}) || ref($data->{'entry'}) ne 'HASH') {
  308. die("Invalid channel data\n");
  309. }
  310. $data = $data->{'entry'};
  311. # Extract the data we want
  312. my %channel = (
  313. 'id' => $data->{'yt$channelId'}->{'$t'},
  314. 'title' => $data->{'title'}->{'$t'},
  315. 'date' => str2time($data->{'published'}->{'$t'}),
  316. 'description' => $data->{'summary'}->{'$t'},
  317. 'thumbnail' => $data->{'media$thumbnail'}->{'url'},
  318. );
  319. return \%channel;
  320. }
  321. sub findVideos($) {
  322. my ($user) = @_;
  323. my %videos = ();
  324. # Prepare the static URL elements
  325. my $baseURL = $URL_PREFIX . $user . $URL_SUFFIX;
  326. my %params = %LIST_PARAMS;
  327. # Loop through until we have all the entries
  328. my $index = 1;
  329. my $itemCount = undef();
  330. LOOP:
  331. {
  332. # Build, fetch, parse
  333. $params{'start-index'} = $index;
  334. my $url = buildURL($baseURL, \%params);
  335. my $data = fetchParse($url);
  336. # Grab the total count, so we know when to stop
  337. if (!exists($data->{'data'})) {
  338. die("Invalid video list\n");
  339. }
  340. $data = $data->{'data'};
  341. if (!defined($itemCount) && exists($data->{'totalItems'})) {
  342. $itemCount = $data->{'totalItems'};
  343. }
  344. # Process each item
  345. if (!exists($data->{'items'}) || ref($data->{'items'}) ne 'ARRAY') {
  346. die("Invalid video list\n");
  347. }
  348. my $items = $data->{'items'};
  349. my $offset = 0;
  350. foreach my $item (@{$items}) {
  351. my %tmp = (
  352. 'number' => ($itemCount - $index - $offset + 1),
  353. 'title' => $item->{'title'},
  354. 'date' => str2time($item->{'uploaded'}),
  355. 'description' => $item->{'description'},
  356. 'duration' => $item->{'duration'},
  357. 'rating' => $item->{'rating'},
  358. 'creator' => $item->{'uploader'},
  359. );
  360. $videos{ $item->{'id'} } = \%tmp;
  361. $offset++;
  362. }
  363. # Loop if there are results left to fetch
  364. $index += $BATCH_SIZE;
  365. if (defined($itemCount) && $itemCount > $index) {
  366. # But don't go past the max supported index
  367. if ($index <= $MAX_INDEX) {
  368. redo LOOP;
  369. }
  370. }
  371. }
  372. if ($DEBUG) {
  373. print STDERR "\nFound " . scalar(keys(%videos)) . ' videos for YouTube user ' . $user . ":\n\t" . join("\n\t", keys(%videos)) . "\n";
  374. if ($DEBUG > 1) {
  375. print STDERR prettyPrint(\%videos, "\t") . "\n";
  376. }
  377. }
  378. return \%videos;
  379. }