File Coverage

blib/lib/App/Automatan/Plugin/Action/YouTube.pm
Criterion Covered Total %
statement 51 235 21.7
branch 0 114 0.0
condition 0 31 0.0
subroutine 17 42 40.4
pod 1 1 100.0
total 69 423 16.3


line stmt bran cond sub pod time code
1             package App::Automatan::Plugin::Action::YouTube;
2              
3             # ABSTRACT: Download module for YouTube videos
4              
5 1     1   643 use strict;
  1         1  
  1         33  
6 1     1   4 use warnings;
  1         1  
  1         27  
7 1     1   469 use Moo;
  1         12096  
  1         7  
8 1     1   1697 use File::Spec::Functions;
  1         630  
  1         81  
9              
10 1     1   691 use Data::Dumper;
  1         5818  
  1         287  
11              
12             sub go {
13 0     0 1   my $self = shift;
14 0           my $in = shift;
15 0           my $bits = shift;
16 0           my $d = $in->{debug};
17            
18 0           my $target = $in->{target};
19            
20 0           foreach my $bit (@$bits) {
21 0           my @urls = $bit =~ /http[s]?:\/\/www.youtube\.com\/watch\?v=.{11}/g;
22 0           foreach my $url (@urls) {
23 0           my $client = WWWYouTubeDownload->new();
24 0           my $video_data;
25 0 0         eval { $video_data = $client->prepare_download($url); }; warn "Error with $url\n".$@ if $@;
  0            
  0            
26             #TODO: Report errors
27 0 0         next unless $video_data;
28 0           my $target_file = catfile($target, $video_data->{title} . '.' . $video_data->{suffix} );
29 0 0         next if -e $target_file;
30 0           _logger($d, "downloading $url to $target_file");
31 0           eval{$client->download( $url, { filename => $target_file } );}
  0            
32             }
33             }
34            
35 0           return 1;
36             }
37              
38             sub _logger {
39 0     0     my $level = shift;
40 0           my $message = shift;
41 0 0         print "$message\n" if $level;
42 0           return 1;
43             }
44              
45             1;
46              
47             # * NOTE:
48             # This portion of code, the package "WWWYouTubeDownload", is copied directly from
49             # the CPAN module WWW::YouTube::Download by XAICRON (Yuji Shimada) and all credit goes to him.
50             # I copied it here because there is an unfixed issue and it has not been updated on CPAN.
51             # There are open pull requests waiting on GitHub and once any of them are merged and released via CPAN
52             # I will go back to just using the CPAN module.
53              
54             package WWWYouTubeDownload;
55              
56 1     1   8 use strict;
  1         2  
  1         30  
57 1     1   4 use warnings;
  1         1  
  1         35  
58 1     1   24 use 5.008001;
  1         3  
  1         52  
59              
60             our $VERSION = '0.56';
61              
62 1     1   8 use Carp qw(croak);
  1         2  
  1         66  
63 1     1   504 use URI ();
  1         3702  
  1         21  
64 1     1   709 use LWP::UserAgent;
  1         31962  
  1         54  
65 1     1   12 use JSON;
  1         1  
  1         9  
66 1     1   813 use HTML::Entities qw/decode_entities/;
  1         4757  
  1         101  
67 1     1   8 use HTTP::Request;
  1         1  
  1         37  
68              
69             $Carp::Internal{ (__PACKAGE__) }++;
70              
71 1     1   3 use constant DEFAULT_FMT => 18;
  1         1  
  1         129  
72              
73             my $base_url = 'http://www.youtube.com/watch?v=';
74              
75             sub new {
76 0     0     my $class = shift;
77 0           my %args = @_;
78 0 0         $args{ua} = LWP::UserAgent->new(
79             #agent => __PACKAGE__.'/'.$VERSION,
80             parse_head => 0,
81             ) unless exists $args{ua};
82 0           bless \%args, $class;
83             }
84              
85             for my $name (qw[video_id video_url title user fmt fmt_list suffix]) {
86 1     1   5 no strict 'refs';
  1         1  
  1         49  
87             *{"get_$name"} = sub {
88 1     1   4 use strict 'refs';
  1         1  
  1         2819  
89 0     0     my ($self, $video_id) = @_;
90 0 0         croak "Usage: $self->get_$name(\$video_id|\$watch_url)" unless $video_id;
91 0           my $data = $self->prepare_download($video_id);
92 0           return $data->{$name};
93             };
94             }
95              
96             sub playback_url {
97 0     0     my ($self, $video_id, $args) = @_;
98 0 0         croak "Usage: $self->playback_url('[video_id|video_url]')" unless $video_id;
99 0   0       $args ||= {};
100              
101 0           my $data = $self->prepare_download($video_id);
102 0   0       my $fmt = $args->{fmt} || $data->{fmt} || DEFAULT_FMT;
103 0   0       my $video_url = $data->{video_url_map}{$fmt}{url} || croak "this video has not supported fmt: $fmt";
104              
105 0           return $video_url;
106             }
107              
108             sub download {
109 0     0     my ($self, $video_id, $args) = @_;
110 0 0         croak "Usage: $self->download('[video_id|video_url]')" unless $video_id;
111 0   0       $args ||= {};
112              
113 0           my $data = $self->prepare_download($video_id);
114              
115 0   0       my $fmt = $args->{fmt} || $data->{fmt} || DEFAULT_FMT;
116              
117 0   0       my $video_url = $data->{video_url_map}{$fmt}{url} || croak "this video has not supported fmt: $fmt";
118 0   0       $args->{filename} ||= $args->{file_name};
119 0   0       my $filename = $self->_format_filename($args->{filename}, {
      0        
120             video_id => $data->{video_id},
121             title => $data->{title},
122             user => $data->{user},
123             fmt => $fmt,
124             suffix => $data->{video_url_map}{$fmt}{suffix} || _suffix($fmt),
125             resolution => $data->{video_url_map}{$fmt}{resolution} || '0x0',
126             });
127              
128 0 0         $args->{cb} = $self->_default_cb({
    0          
129             filename => $filename,
130             verbose => $args->{verbose},
131             overwrite => defined $args->{overwrite} ? $args->{overwrite} : 1,
132             }) unless ref $args->{cb} eq 'CODE';
133              
134 0           my $res = $self->ua->get($video_url, ':content_cb' => $args->{cb});
135 0 0         croak "!! $video_id download failed: ", $res->status_line if $res->is_error;
136             }
137              
138             sub _format_filename {
139 0     0     my ($self, $filename, $data) = @_;
140 0 0         return "$data->{video_id}.$data->{suffix}" unless defined $filename;
141 0 0         $filename =~ s#{([^}]+)}#$data->{$1} || "{$1}"#eg;
  0            
142 0           return $filename;
143             }
144              
145             sub _is_supported_fmt {
146 0     0     my ($self, $video_id, $fmt) = @_;
147 0           my $data = $self->prepare_download($video_id);
148 0 0         $data->{video_url_map}{$fmt}{url} ? 1 : 0;
149             }
150              
151             sub _default_cb {
152 0     0     my ($self, $args) = @_;
153 0           my ($file, $verbose, $overwrite) = @$args{qw/filename verbose overwrite/};
154              
155 0 0 0       croak "file exists! $file" if -f $file and !$overwrite;
156 0 0         open my $wfh, '>', $file or croak $file, " $!";
157 0           binmode $wfh;
158              
159 0 0         print "Downloading `$file`\n" if $verbose;
160             return sub {
161 0     0     my ($chunk, $res, $proto) = @_;
162 0           print $wfh $chunk; # write file
163              
164 0 0 0       if ($verbose || $self->{verbose}) {
165 0           my $size = tell $wfh;
166 0           my $total = $res->header('Content-Length');
167 0           printf "%d/%d (%.2f%%)\r", $size, $total, $size / $total * 100;
168 0 0         print "\n" if $total == $size;
169             }
170 0           };
171             }
172              
173             sub prepare_download {
174 0     0     my ($self, $video_id) = @_;
175 0 0         croak "Usage: $self->prepare_download('[video_id|watch_url]')" unless $video_id;
176 0           $video_id = $self->video_id($video_id);
177              
178 0 0         return $self->{cache}{$video_id} if ref $self->{cache}{$video_id} eq 'HASH';
179              
180 0           my $content = $self->_get_content($video_id);
181 0           my $title = $self->_fetch_title($content);
182 0           my $user = $self->_fetch_user($content);
183 0           my $video_url_map = $self->_fetch_video_url_map($content);
184              
185 0           my $fmt_list = [];
186 0           my $sorted = [
187             map {
188 0           push @$fmt_list, $_->[0]->{fmt};
189 0           $_->[0]
190             } sort {
191 0           $b->[1] <=> $a->[1]
192             } map {
193 0           my $resolution = $_->{resolution};
194 0           $resolution =~ s/(\d+)x(\d+)/$1 * $2/e;
  0            
195 0           [ $_, $resolution ]
196             } values %$video_url_map,
197             ];
198              
199 0           my $hq_data = $sorted->[0];
200              
201 0           return $self->{cache}{$video_id} = {
202             video_id => $video_id,
203             video_url => $hq_data->{url},
204             title => $title,
205             user => $user,
206             video_url_map => $video_url_map,
207             fmt => $hq_data->{fmt},
208             fmt_list => $fmt_list,
209             suffix => $hq_data->{suffix},
210             resolution => $hq_data->{resolution},
211             };
212             }
213              
214             sub _fetch_title {
215 0     0     my ($self, $content) = @_;
216              
217 0 0         my ($title) = $content =~ // or return;
218 0           return decode_entities($title);
219             }
220              
221             sub _fetch_user {
222 0     0     my ($self, $content) = @_;
223              
224 0 0         my ($user) = $content =~ /([^<]+)<\/span>/ or return;
225 0           return decode_entities($user);
226             }
227              
228             sub _fetch_video_url_map {
229 0     0     my ($self, $content) = @_;
230              
231 0           my $args = $self->_get_args($content);
232 0 0 0       unless ($args->{fmt_list} and $args->{url_encoded_fmt_stream_map}) {
233 0           croak 'failed to find video urls';
234             }
235              
236 0           my $fmt_map = _parse_fmt_map($args->{fmt_list});
237 0           my $fmt_url_map = _parse_stream_map($args->{url_encoded_fmt_stream_map});
238              
239 0           my $video_url_map = +{
240             map {
241 0           $_->{fmt} => $_,
242             } map +{
243             fmt => $_,
244             resolution => $fmt_map->{$_},
245             url => $fmt_url_map->{$_},
246             suffix => _suffix($_),
247             }, keys %$fmt_map
248             };
249              
250 0           return $video_url_map;
251             }
252              
253             sub _get_content {
254 0     0     my ($self, $video_id) = @_;
255              
256 0           my $url = "$base_url$video_id";
257              
258 0           my $req = HTTP::Request->new;
259 0           $req->method('GET');
260 0           $req->uri($url);
261 0           $req->header('Accept-Language' => 'en-US');
262              
263 0           my $res = $self->ua->request($req);
264 0 0         croak "GET $url failed. status: ", $res->status_line if $res->is_error;
265              
266 0           return $res->content;
267             }
268              
269             sub _get_args {
270 0     0     my ($self, $content) = @_;
271              
272 0           my $data;
273 0           for my $line (split "\n", $content) {
274 0 0         next unless $line;
275 0 0         if ($line =~ /the uploader has not made this video available in your country/i) {
    0          
276 0           croak 'Video not available in your country';
277             }
278             elsif ($line =~ /^.+ytplayer\.config\s*=\s*({.*})/) {
279 0           ($data, undef) = JSON->new->utf8(1)->decode_prefix($1);
280 0           last;
281             }
282             }
283              
284 0 0         croak 'failed to extract JSON data' unless $data->{args};
285              
286 0           return $data->{args};
287             }
288              
289             sub _parse_fmt_map {
290 0     0     my $param = shift;
291 0           my $fmt_map = {};
292 0           for my $stuff (split ',', $param) {
293 0           my ($fmt, $resolution) = split '/', $stuff;
294 0           $fmt_map->{$fmt} = $resolution;
295             }
296              
297 0           return $fmt_map;
298             }
299              
300             sub _sigdecode {
301 0     0     my @s = @_;
302              
303             # based on youtube_dl/extractor/youtube.py from yt-dl.org
304 0 0         if (@s == 92) {
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
305 0           return ($s[25], @s[3..24], $s[0], @s[26..41], $s[79], @s[43..78], $s[91], @s[80..82]);
306             } elsif (@s == 90) {
307 0           return ($s[25], @s[3..24], $s[2], @s[26..39], $s[77], @s[41..76], $s[89], @s[78..80]);
308             } elsif (@s == 88) {
309 0           return ($s[48], reverse(@s[68..81]), $s[82], reverse(@s[63..66]), $s[85],
310             reverse(@s[49..61]), $s[67], reverse(@s[13..47]), $s[3],
311             reverse(@s[4..11]), $s[2], $s[12]);
312             } elsif (@s == 87) {
313 0           return (@s[4..22], $s[86], @s[24..84]);
314             } elsif (@s == 86) {
315 0           return (@s[2..62], $s[82], @s[64..81], $s[63]);
316             } elsif (@s == 85) {
317 0           return (@s[2..7], $s[0], @s[9..20], $s[65], @s[22..64], $s[84], @s[66..81], $s[21]);
318             } elsif (@s == 84) {
319 0           return (reverse(@s[37..83]), $s[2], reverse(@s[27..35]), $s[3],
320             reverse(@s[4..25]), $s[26]);
321             } elsif (@s == 83) {
322 0           return ($s[6], @s[3..5], $s[33], @s[7..23], $s[0], @s[25..32], $s[53], @s[34..52], $s[24], @s[54..82]);
323             } elsif (@s == 82) {
324 0           return ($s[36], reverse(@s[68..79]), $s[81], reverse(@s[41..66]), $s[33],
325             reverse(@s[37..39]), $s[40], $s[35], $s[0], $s[67],
326             reverse(@s[1..32]), $s[34]);
327             } elsif (@s == 81) {
328 0           return ($s[56], reverse(@s[57..79]), $s[41], reverse(@s[42..55]), $s[80],
329             reverse(@s[35..40]), $s[0], reverse(@s[30..33]), $s[34],
330             reverse(@s[10..28]), $s[29], reverse(@s[1..8]), $s[9]);
331             } elsif (@s == 79) {
332 0           return ($s[54], reverse(@s[55..77]), $s[39], reverse(@s[40..53]), $s[78],
333             reverse(@s[35..38]), $s[0], reverse(@s[30..33]), $s[34],
334             reverse(@s[10..28]), $s[29], reverse(@s[1..8]), $s[9]);
335             }
336              
337 0           return (); # fail
338             }
339              
340             sub _getsig {
341 0     0     my $sig = shift;
342 0 0         croak 'Unable to find signature' unless $sig;
343 0           my @sig = _sigdecode(split(//, $sig));
344 0 0         croak "Unable to decode signature $sig of length " . length($sig) unless @sig;
345 0           return join('', @sig);
346             }
347              
348             sub _parse_stream_map {
349 0     0     my $param = shift;
350 0           my $fmt_url_map = {};
351 0           for my $stuff (split ',', $param) {
352 0           my $uri = URI->new;
353 0           $uri->query($stuff);
354 0           my $query = +{ $uri->query_form };
355 0           $fmt_url_map->{$query->{itag}} = $query->{url};
356             }
357              
358 0           return $fmt_url_map;
359             }
360              
361             sub ua {
362 0     0     my ($self, $ua) = @_;
363 0 0         return $self->{ua} unless $ua;
364 0 0         croak "Usage: $self->ua(\$LWP_LIKE_OBJECT)" unless eval { $ua->isa('LWP::UserAgent') };
  0            
365 0           $self->{ua} = $ua;
366             }
367              
368             sub _suffix {
369 0     0     my $fmt = shift;
370 0 0         return $fmt =~ /43|44|45/ ? 'webm'
    0          
    0          
371             : $fmt =~ /18|22|37|38/ ? 'mp4'
372             : $fmt =~ /13|17/ ? '3gp'
373             : 'flv'
374             ;
375             }
376              
377             sub video_id {
378 0     0     my ($self, $stuff) = @_;
379 0 0         return unless $stuff;
380 0 0         if ($stuff =~ m{/.*?[?&;!](?:v|video_id)=([^&#?=/;]+)}) {
    0          
    0          
    0          
381 0           return $1;
382             }
383             elsif ($stuff =~ m{/(?:e|v|embed)/([^&#?=/;]+)}) {
384 0           return $1;
385             }
386             elsif ($stuff =~ m{#p/(?:u|search)/\d+/([^&?/]+)}) {
387 0           return $1;
388             }
389             elsif ($stuff =~ m{youtu.be/([^&#?=/;]+)}) {
390 0           return $1;
391             }
392             else {
393 0           return $stuff;
394             }
395             }
396              
397             sub playlist_id {
398 0     0     my ($self, $stuff) = @_;
399 0 0         return unless $stuff;
400 0 0         if ($stuff =~ m{/.*?[?&;!]list=([^&#?=/;]+)}) {
    0          
401 0           return $1;
402             }
403             elsif ($stuff =~ m{^\s*([FP]L[\w\-]+)\s*$}) {
404 0           return $1;
405             }
406 0           return $stuff;
407             }
408              
409             sub user_id {
410 0     0     my ($self, $stuff) = @_;
411 0 0         return unless $stuff;
412 0 0         if ($stuff =~ m{/user/([^&#?=/;]+)}) {
413 0           return $1;
414             }
415 0           return $stuff;
416             }
417              
418             1;
419              
420             __END__