Add sane user-agent header for Async*FileGetters
[videosite.git] / videosite.pl
1 # autodownload flash videos
2 #
3 # (c) 2007-2008 by Ralf Ertzinger <ralf@camperquake.de>
4 # licensed under GNU GPL v2
5 #
6 # Based on youtube.pl by Christian Garbs <mitch@cgarbs.de>
7 # which in turn is
8 # based on trigger.pl by Wouter Coekaerts <wouter@coekaerts.be>
9
10
11 BEGIN {
12     # Get rid of a (possibly old) version of BettIrssi
13     # This is a hack to prevent having to reload irssi just
14     # because BettIrssi.pm changed
15
16     delete($INC{'BettIrssi.pm'});
17 }
18
19 use strict;
20 use Irssi 20020324 qw (command_bind command_runsub signal_add_first signal_add_last);
21 use vars qw($VERSION %IRSSI);
22 use XML::Simple;
23 use Data::Dumper;
24 use File::Spec;
25 use File::Temp qw(tempfile);
26 use BettIrssi 101 qw(_bcb _bcs);
27 use LWP::UserAgent;
28 use JSON -support_by_pp;
29
30 my @grabbers;
31 my @getters;
32 my $getter;
33 my $conf;
34 my $xmlconffile = File::Spec->catfile(Irssi::get_irssi_dir(), 'videosite.xml');
35 my $conffile = File::Spec->catfile(Irssi::get_irssi_dir(), 'videosite.json');
36 my $scriptdir = File::Spec->catfile(Irssi::get_irssi_dir(), 'scripts');
37 my $plugindir = File::Spec->catfile($scriptdir, 'videosite');
38 my @outputstack = (undef);
39
40 my $PARAMS = {
41     'getter' => '',
42     'mode' => 'download',
43     'connectorlist' => ['direct'],
44     'connectors' => {},
45 };
46
47
48 # activate debug here
49 my $debug = 0;
50
51 # "message public", SERVER_REC, char *msg, char *nick, char *address, char *target
52 signal_add_last(_bcs("message public" => sub {check_for_link(@_)}));
53 # "message own_public", SERVER_REC, char *msg, char *target
54 signal_add_last(_bcs("message own_public" => sub {check_for_link(@_)}));
55
56 # "message private", SERVER_REC, char *msg, char *nick, char *address
57 signal_add_last(_bcs("message private" => sub {check_for_link(@_)}));
58 # "message own_private", SERVER_REC, char *msg, char *target, char *orig_target
59 signal_add_last(_bcs("message own_private" => sub {check_for_link(@_)}));
60
61 # "message irc action", SERVER_REC, char *msg, char *nick, char *address, char *target
62 signal_add_last(_bcs("message irc action" => sub {check_for_link(@_)}));
63 # "message irc own_action", SERVER_REC, char *msg, char *target
64 signal_add_last(_bcs("message irc own_action" => sub {check_for_link(@_)}));
65
66 # For tab completion
67 # This does not use BettIrssi (yet)
68 signal_add_first('complete word', \&sig_complete);
69
70 sub push_output {
71     unshift(@outputstack, shift);
72 }
73
74 sub pop_output {
75     shift(@outputstack);
76
77     @outputstack = (undef) unless (@outputstack);
78 }
79
80 my $videosite_commands = {
81     'save' => sub {
82         cmd_save();
83     },
84
85     'set' => sub {
86         cmd_set(@_);
87     },
88     
89     'show' => sub {
90         cmd_show(@_);
91     },
92
93     'help' => sub {
94         cmd_help(@_);
95     },
96
97     'getter' => sub {
98         cmd_getter(@_);
99     },
100
101     'enable' => sub {
102         cmd_enable(@_);
103     },
104
105     'disable' => sub {
106         cmd_disable(@_);
107     },
108
109     'reload' => sub {
110         init_videosite(0);
111     },
112
113     'mode' => sub {
114         cmd_mode(@_);
115     },
116
117     'connector' => sub {
118         cmd_connector(@_);
119     },
120
121     'debug' => sub {
122         $debug = 1;
123         foreach (@grabbers, @getters) {
124             $_->setdebug(1);
125         }
126         write_irssi('Enabled debugging');
127     },
128
129     'nodebug' => sub {
130         $debug = 0;
131         foreach (@grabbers, @getters) {
132             $_->setdebug(0);
133         }
134         write_irssi('Disabled debugging');
135     },
136 };
137
138 sub write_irssi {
139     my @text = @_;
140     my $output = $outputstack[0];
141
142     my $format = "%%mvideosite: %%n" . shift(@text);
143
144     # escape % in parameters from irssi
145     s/%/%%/g foreach @text;
146
147     if (defined $output) {
148         $output->(sprintf($format, @text), MSGLEVEL_CLIENTCRAP);
149     } else {
150         Irssi::print(sprintf($format, @text));
151     }
152
153 }
154
155 sub write_debug {
156     if ($debug) {
157         write_irssi(@_);
158     }
159 }
160
161 sub expand_url_shortener {
162     my $s = shift;
163     my $os = '';
164     my @urlshortener = (
165         'is\.gd/[[:alnum:]]+',
166         'otf\.me/[[:alnum:]]+',
167         'hel\.me/[[:alnum:]]+',
168         '7ax\.de/[[:alnum:]]+',
169         'ow\.ly/[[:alnum:]]+',
170         'j\.mp/[[:alnum:]]+',
171         'bit\.ly/[[:alnum:]]+',
172         'tinyurl\.com/[[:alnum:]]+',
173         'pop\.is/[[:alnum:]]+',
174         'post\.ly/[[:alnum:]]+',
175         '1\.ly/[[:alnum:]]+',
176         '2\.ly/[[:alnum:]]+',
177         't\.co/[[:alnum:]]+',
178         'shar\.es/[[:alnum:]]+',
179         'goo\.gl/[[:alnum:]]+',
180         );
181     my $ua = LWP::UserAgent->new(agent => 'Mozilla', max_redirect => 0, timeout => 5);
182     my $i = 10;
183
184     OUTER: while (($os ne $s) and ($i > 0)) {
185         study($s);
186         $os = $s;
187         $i--;
188
189         foreach my $pattern (@urlshortener) {
190             my $p = "https?:\/\/" . $pattern;
191
192             write_debug("Matching %s against %s", $p, $s);
193             if ($s =~ m|($p)|) {
194                 my $matched = $1;
195                 my $res;
196
197                 write_debug("Found %s", $matched);
198                 $res = $ua->head($matched);
199                 if ($res->is_redirect()) {
200                     my $new = $res->headers()->header("Location");
201
202                     write_debug("Replacing %s with %s", $matched, $new);
203                     $s =~ s/$matched/$new/;
204                     next OUTER;
205                 } else {
206                     write_debug("Error resolving %s", $matched);
207                 }
208             }
209         }
210     }
211
212     if ($i == 0) {
213         write_debug("Loop terminated by counter");
214     }
215
216     write_debug("Final string: %s", $s);
217
218     return $s;
219 }
220
221 sub connectorlist {
222     my @c;
223
224     foreach (@{$conf->{'videosite'}->{'connectorlist'}}) {
225         push(@c, $conf->{'videosite'}->{'connectors'}->{$_});
226     }
227
228     return @c;
229 }
230
231
232 sub check_for_link {
233     my $event = shift;
234     my $message = $event->message();
235     my $witem = $event->channel();
236     my $g;
237     my $m;
238     my $p;
239
240
241     # Look if we should ignore this line
242     if ($message =~ m,(?:\s|^)/nosave(?:\s|$),) {
243         return;
244     }
245
246     push_output($event->ewpf);
247     $message = expand_url_shortener($message);
248
249     study($message);
250
251     # Offer the message to all Grabbers in turn
252     GRABBER: foreach $g (@grabbers) {
253         ($m, $p) = $g->get($message);
254         while (defined($m)) {
255             write_debug('Metadata: %s', Dumper($m));
256             if ('download' eq ($conf->{'videosite'}->{'mode'})) {
257                 write_irssi('%%R>>> %%NSaving %%Y%s%%N %%G%s', $m->{'SOURCE'}, $m->{'TITLE'});
258                 unless($getter->get($m)) {
259                     write_irssi('%%R>>> FAILED');
260                 }
261             } elsif ('display' eq ($conf->{'videosite'}->{'mode'})) {
262                 write_irssi('%%M>>> %%NSaw %%Y%s%%N %%G%s', $m->{'SOURCE'}, $m->{'TITLE'});
263             } else {
264                 write_irssi('%%R>>> Invalid operation mode');
265             }
266
267             # Remove the matched part from the message and try again (there may be
268             # more!)
269             $message =~ s/$p//;
270             study($message);
271             last GRABBER if ($message =~ /^\s*$/);
272
273             ($m, $p) = $g->get($message);
274         }
275     }
276
277     pop_output();
278 }
279
280 sub cmd_save {
281
282     print Dumper($conf);
283     eval {
284         my ($tempfile, $tempfn) = tempfile("videosite.json.XXXXXX", dir => Irssi::get_irssi_dir());
285         print $tempfile JSON->new->pretty->utf8->encode($conf);
286         close($tempfile);
287         rename($tempfn, $conffile);
288     };
289     if ($@) {
290         write_irssi('Could not save config to %s: %s', ($conffile, $@));
291     } else {
292         write_irssi('configuration saved to %s', $conffile);
293     }
294 }
295
296 sub cmd_set {
297     my $target = shift;
298     my $key = shift;
299     my $val = shift;
300     my $p;
301
302     foreach $p (@getters, @grabbers) {
303         if ($p->{'NAME'} eq $target) {
304             $p->setval($key, $val);
305             return;
306         }
307     }
308     write_irssi('No such module');
309 }
310
311
312 sub cmd_enable {
313     my $target = shift;
314     my $p;
315
316     foreach $p (@grabbers) {
317         if ($p->{'NAME'} eq $target) {
318             $p->enable();
319             return;
320         }
321     }
322     write_irssi('No such module');
323 }
324
325
326 sub cmd_disable {
327     my $target = shift;
328     my $p;
329
330     foreach $p (@grabbers) {
331         if ($p->{'NAME'} eq $target) {
332             $p->disable();
333             return;
334         }
335     }
336     write_irssi('No such module');
337 }
338
339
340 sub cmd_show {
341     my $target = shift;
342     my $p;
343     my $e;
344
345     if (defined($target)) {
346         foreach $p (@getters, @grabbers) {
347             if ($p->{'NAME'} eq $target) {
348                 write_irssi($p->getconfstr());
349                 return;
350             }
351         }
352         write_irssi('No such module');
353     } else {
354         write_irssi('Loaded grabbers (* denotes enabled modules):');
355         foreach $p (@grabbers) {
356             $e = $p->_getval('enabled');
357             write_irssi(' %s%s', $p->{'NAME'}, $e?'*':'');
358         };
359
360         write_irssi('Loaded getters:');
361         foreach $p (@getters) {
362             write_irssi(' %s', $p->{'NAME'});
363         };
364     }
365 }
366
367 sub cmd_help {
368     my $target = shift;
369     my $p;
370
371     if (defined($target)) {
372         foreach $p (@getters, @grabbers) {
373             if ($p->{'NAME'} eq $target) {
374                 write_irssi($p->gethelpstr());
375                 return;
376             }
377         }
378         write_irssi('No such module');
379     } else {
380         write_irssi(<<'EOT');
381 Supported commands:
382  save: save the current configuration
383  help [modulename]: display this help, or module specific help
384  show [modulename]: show loaded modules, or the current parameters of a module
385  set modulename parameter value: set a module parameter to a new value
386  getter [modulename]: display or set the getter to use
387  enable [modulename]: enable the usage of this module (grabbers only)
388  disable [modulename]: disable the usage of this module (grabbers only)
389  reload: reload all modules (this is somewhat experimental)
390  mode [modename]: display or set the operation mode (download/display)
391  connector [subcommand]: manage connectors (proxies)
392  debug: enable debugging messages
393  nodebug: disable debugging messages
394 EOT
395     }
396 }
397
398 sub cmd_getter {
399     my $target = shift;
400     my $p;
401
402     if (defined($target)) {
403         foreach $p (@getters) {
404             if ($p->{'NAME'} eq $target) {
405                 $getter = $p;
406                 $conf->{'videosite'}->{'getter'} = $target;
407                 write_irssi("Getter changed to %s", $target);
408                 return;
409             }
410         }
411         write_irssi('No such getter');
412     } else {
413         write_irssi('Current getter: %s', $conf->{'videosite'}->{'getter'});
414     }
415 }
416
417 sub cmd_mode {
418     my $mode = shift;
419
420     if (defined($mode)) {
421         $mode = lc($mode);
422         if (('download' eq $mode) or ('display' eq $mode)) {
423             $conf->{'videosite'}->{'mode'} = $mode;
424             write_irssi('Now using %s mode', $mode);
425         } else {
426             write_irssi('Invalid mode: %s', $mode);
427         }
428     } else {
429         write_irssi('Current mode: %s', $conf->{'videosite'}->{'mode'});
430     }
431 }
432
433 sub cmd_connector {
434     my $subcmd = shift;
435     my $connconf = $conf->{'videosite'}->{'connectors'};
436
437     unless(defined($subcmd)) {
438         $subcmd = "help";
439     }
440
441     $subcmd = lc($subcmd);
442
443     if ($subcmd eq 'list') {
444         write_irssi("Defined connectors");
445         foreach (keys(%{$connconf})) {
446             write_irssi($_);
447             my $schemas = $connconf->{$_}->{'schemas'};
448             if (scalar(keys(%{$schemas})) == 0) {
449                 write_irssi(" No schemas defined");
450             } else {
451                 foreach (keys(%{$schemas})) {
452                     write_irssi(' %s: %s', $_, $schemas->{$_});
453                 }
454             }
455         }
456
457         write_irssi();
458         write_irssi("Selected connectors: %s", join(", ", @{$conf->{'videosite'}->{'connectorlist'}}));
459     } elsif ($subcmd eq 'add') {
460         my ($name) = @_;
461
462         unless(defined($name)) {
463             write_irssi("No name given");
464             return;
465         }
466
467         $name = lc($name);
468
469         if (exists($connconf->{$_})) {
470             write_irssi("Connector already exists");
471             return;
472         }
473
474         $connconf->{$name} = {'name' => $name, 'schemas' => {}};
475     } elsif ($subcmd eq 'del') {
476         my ($name) = @_;
477
478         unless(defined($name)) {
479             write_irssi("No name given");
480             return;
481         }
482
483         $name = lc($name);
484
485         unless (exists($connconf->{$name})) {
486             write_irssi("Connector does not exist");
487             return;
488         }
489
490         if (exists($connconf->{$name}->{'_immutable'})) {
491             write_irssi("Connector cannot be removed");
492             return;
493         }
494
495         delete($connconf->{$name});
496
497         # Remove from list of active connectors
498         $conf->{'videosite'}->{'connectorlist'} =
499             [ grep { $_ ne $name } @{$conf->{'videosite'}->{'connectorlist'}} ];
500
501         if (scalar(@{$conf->{'videosite'}->{'connectorlist'}}) == 0) {
502             write_irssi("List of selected connectors is empty, resetting to direct");
503             $conf->{'videosite'}->{'connectorlist'} = [ 'direct' ];
504         }
505     } elsif ($subcmd eq 'addschema') {
506         my ($conn, $schema, $proxy) = @_;
507
508         unless(defined($conn)) {
509             write_irssi("No connector name given");
510             return;
511         }
512
513         $conn = lc($conn);
514
515         if (exists($connconf->{$conn}->{'_immutable'})) {
516             write_irssi("Connector cannot be modified");
517             return;
518         }
519
520         unless(defined($schema)) {
521             write_irssi("No schema given");
522             return;
523         }
524
525         $schema = lc($schema);
526
527         unless(defined($proxy)) {
528             write_irssi("No proxy given");
529             return;
530         }
531
532         unless(exists($connconf->{$conn})) {
533             write_irssi("Connector does not exist");
534             return;
535         }
536
537         $connconf->{$conn}->{'schemas'}->{$schema} = $proxy;
538     } elsif ($subcmd eq 'delschema') {
539         my ($conn, $schema) = @_;
540
541         unless(defined($conn)) {
542             write_irssi("No connector name given");
543             return;
544         }
545
546         $conn = lc($conn);
547
548         if (exists($connconf->{$conn}->{'_immutable'})) {
549             write_irssi("Connector cannot be modified");
550             return;
551         }
552
553         unless(defined($schema)) {
554             write_irssi("No schema given");
555             return;
556         }
557
558         $schema = lc($schema);
559
560         unless(exists($connconf->{$conn})) {
561             write_irssi("Connector does not exist");
562             return;
563         }
564
565         delete($connconf->{$conn}->{'schemas'}->{$schema});
566     } elsif ($subcmd eq 'select') {
567         my @connlist = map { lc } @_;
568
569         if (scalar(@connlist) == 0) {
570             write_irssi("No connectors given");
571             return;
572         }
573
574         foreach (@connlist) {
575             unless(exists($connconf->{$_})) {
576                 write_irssi("Connector %s does not exist", $_);
577                 return;
578             }
579         }
580
581         $conf->{'videosite'}->{'connectorlist'} = [ @connlist ];
582     } else {
583         write_irssi("connector [list|add|del|addschema|delschema|help] <options>");
584         write_irssi(" help: Show this help");
585         write_irssi(" list: List the defined connectors");
586         write_irssi(" add <name>: Add a connector with name <name>");
587         write_irssi(" del <name>: Delete the connector with name <name>");
588         write_irssi(" addschema <name> <schema> <proxy>: Add proxy to connector for the given schema");
589         write_irssi(" delschema <name> <schema>: Remove the schema from the connector");
590         write_irssi(" select <name> [<name>...]: Select the connectors to use");
591     }
592 }
593
594
595
596
597 # save on unload
598 sub sig_command_script_unload {
599     my $script = shift;
600     if ($script =~ /(.*\/)?videosite(\.pl)?$/) {
601         cmd_save();
602     }
603 }
604
605 sub ploader {
606
607     my $dir = shift;
608     my $pattern = shift;
609     my $type = shift;
610     my @list;
611     my $p;
612     my $g;
613     my @g = ();
614
615     opendir(D, $dir) || return ();
616     @list = grep {/$pattern/ && -f File::Spec->catfile($dir, $_) } readdir(D);
617     closedir(D);
618
619     foreach $p (@list) {
620         write_debug("Trying to load $p:");
621         $p =~ s/\.pm$//;
622         eval qq{ require videosite::$p; };
623         if ($@) {
624             write_irssi("Failed to load plugin: $@");
625             next;
626         }
627
628         $g = eval qq{ videosite::$p->new(); };
629         if ($@) {
630             write_irssi("Failed to instanciate: $@");
631             delete($INC{$p});
632             next;
633         }
634
635         write_debug("found $g->{'TYPE'} $g->{'NAME'}");
636         if ($type eq $g->{'TYPE'}) {
637             push(@g, $g);
638             $g->setio(\&write_irssi);
639             $g->setconn(\&connectorlist);
640         } else {
641             write_irssi('%s has wrong type (got %s, expected %s)', $p, $g->{'TYPE'}, $type);
642             delete($INC{$p});
643         }
644     }
645
646     write_debug("Loaded %d plugins", $#g+1);
647     
648     return @g;
649 }
650
651 sub _load_modules($) {
652
653     my $path = shift;
654
655     foreach (keys(%INC)) {
656         if ($INC{$_} =~ m|^$path|) {
657             write_debug("Removing %s from \$INC", $_);
658             delete($INC{$_});
659         }
660     }
661     @grabbers = ploader($path, '.*Grabber\.pm$', 'grabber');
662     @getters = ploader($path, '.*Getter\.pm$', 'getter');
663 }
664
665
666 sub init_videosite {
667
668     my $bindings = shift;
669     my $p;
670
671     if (-r $conffile) {
672         write_debug("Attempting JSON config load from %s", $conffile);
673         eval {
674             local $/;
675             open(CONF, '<', $conffile);
676             $conf = JSON->new->utf8->decode(<CONF>);
677             close(CONF);
678         };
679     } elsif (-r $xmlconffile) {
680         write_debug("Attempting XML config load from %s", $xmlconffile);
681         $conf = XML::Simple::XMLin($xmlconffile, ForceArray => ['config', 'option', 'connectorlist'], KeepRoot => 1, KeyAttr => {'connector' => '+name', 'config' => 'module', 'option' => 'key'});
682     }
683
684     unless(defined($conf)) {
685         # No config, start with an empty one
686         write_debug('No config found, using defaults');
687         $conf = { 'videosite' => { }};
688     }
689
690     foreach (keys(%{$PARAMS})) {
691         unless (exists($conf->{'videosite'}->{$_})) {
692             $conf->{'videosite'}->{$_} = $PARAMS->{$_};
693         }
694     }
695
696     # Make sure there is a connector called 'direct', which defines no
697     # proxies
698     unless (exists($conf->{'videosite'}->{'connectors'}->{'direct'})) {
699         $conf->{'videosite'}->{'connectors'}->{'direct'} = {
700                 'name' => 'direct',
701                 '_immutable' => '1',
702                 'schemas' => {},
703         };
704     }
705
706     _load_modules($plugindir);
707
708     unless (defined(@grabbers) && defined(@getters)) {
709         write_irssi('No grabbers or no getters found, can not proceed.');
710         return;
711     }
712
713     $getter = $getters[0];
714     foreach $p (@getters) {
715         if ($conf->{'videosite'}->{'getter'} eq $p->{'NAME'}) {
716             $getter = $p;
717         }
718     }
719     write_debug('Selected %s as getter', $getter->{'NAME'});
720     $conf->{'videosite'}->{'getter'} = $getter->{'NAME'};
721
722     # Loop through all plugins and load the config
723     foreach $p (@grabbers, @getters) {
724         $conf->{'videosite'}->{'config'}->{$p->{'NAME'}} = $p->mergeconfig($conf->{'videosite'}->{'config'}->{$p->{'NAME'}});
725     }
726
727     if ($bindings) {
728
729         Irssi::signal_add_first('command script load', 'sig_command_script_unload');
730         Irssi::signal_add_first('command script unload', 'sig_command_script_unload');
731         Irssi::signal_add('setup saved', 'cmd_save');
732
733
734         Irssi::command_bind(_bcb('videosite' => \&cmdhandler));
735     }
736
737     write_irssi('initialized successfully');
738 }
739
740 sub sig_complete {
741     my ($complist, $window, $word, $linestart, $want_space) = @_;
742     my @matches;
743
744     if ($linestart !~ m|^/videosite\b|) {
745         return;
746     }
747
748     if ('/videosite' eq $linestart) {
749         # No command enterd so far. Produce a list of possible follow-ups
750         @matches = grep {/^$word/} keys (%{$videosite_commands});
751     } elsif ('/videosite set' eq $linestart) {
752         # 'set' command entered. Produce a list of modules
753         foreach (@grabbers, @getters) {
754             push(@matches, $_->{'NAME'}) if $_->{'NAME'} =~ m|^$word|;
755         };
756     } elsif ($linestart =~ m|^/videosite set (\w+)$|) {
757         my $module = $1;
758
759         foreach my $p (@getters, @grabbers) {
760             if ($p->{'NAME'} eq $module) {
761                 @matches = $p->getparamlist($word);
762                 last;
763             }
764         }
765     } elsif ($linestart =~ m|/videosite set (\w+) (\w+)$|) {
766         my $module = $1;
767         my $param = $2;
768
769         foreach my $p (@getters, @grabbers) {
770             if ($p->{'NAME'} eq $module) {
771                 @matches = $p->getparamvalues($param, $word);
772                 last;
773             }
774         }
775     }
776
777
778     push(@{$complist}, sort @matches);
779     ${$want_space} = 0;
780
781     Irssi::signal_stop();
782 }
783
784 sub cmdhandler {
785     my $event = shift;
786     my ($cmd, @params) = split(/\s+/, $event->message());
787
788     push_output($event->ewpf);
789
790     if (exists($videosite_commands->{$cmd})) {
791         $videosite_commands->{$cmd}->(@params);
792     }
793
794     pop_output();
795 }
796
797 unshift(@INC, $scriptdir);
798 init_videosite(1);