]> git.draconx.ca Git - mpdhacks.git/blob - mpdmenu.pl
d22193c960fa2a778a4ef9b0227f76235830233c
[mpdhacks.git] / mpdmenu.pl
1 #!/usr/bin/perl
2 #
3 # Copyright © 2008,2010,2012,2019 Nick Bowler
4 #
5 # Silly little script to generate an FVWM menu with various bits of MPD
6 # status information and controls.
7 #
8 # License GPLv3+: GNU General Public License version 3 or any later version.
9 # This is free software: you are free to change and redistribute it.
10 # There is NO WARRANTY, to the extent permitted by law.
11
12 use strict;
13
14 use utf8;
15
16 use Encode qw(decode encode);
17 use Encode::Locale qw(decode_argv);
18 decode_argv(Encode::FB_CROAK);
19 binmode(STDOUT, ":utf8");
20
21 use IO::Socket::INET6;
22 use Getopt::Long qw(:config gnu_getopt);
23 use Scalar::Util qw(reftype);
24 use List::Util qw(any);
25 use FindBin;
26
27 use constant {
28         MPD_MJR_MIN => 0,
29         MPD_MNR_MIN => 21,
30         MPD_REV_MIN => 0,
31 };
32
33 my $SELF = "$FindBin::Bin/$FindBin::Script";
34
35 my $MUSIC = $ENV{MUSIC}    // "/srv/music";
36 my $host  = $ENV{MPD_HOST} // "localhost";
37 my $port  = $ENV{MPD_PORT} // "6600";
38 my $sock;
39
40 my $menu;
41 my $mode = "top";
42
43 # Quotes the argument so that it is presented as a single argument to MPD
44 # at the protocol level.  This also works OK for most FVWM arguments.
45 sub escape {
46         my $s = @_[0] // $_;
47
48         # No way to encode literal newlines in the protocol, so we
49         # convert any newlines in the arguments into a space, which
50         # can help with quoting.
51         $s =~ s/\n/ /g;
52
53         if (/[ \t\\"]/) {
54                 $s =~ s/[\\"]/\\$&/g;
55                 return "\"$s\"";
56         }
57
58         $s =~ s/^\s*$/"$&"/;
59         return $s;
60 }
61
62 # Submit a command to the MPD server; each argument to this function
63 # is quoted and sent as a single argument to MPD.
64 sub mpd_exec {
65         my $cmd = join(' ', map { escape } @_);
66
67         print $sock "$cmd\n";
68 }
69
70 sub fvwm_cmd_unquoted {
71         print join(' ', @_), "\n";
72 }
73
74 sub fvwm_cmd {
75         fvwm_cmd_unquoted(map { escape } @_);
76 }
77
78 # Quotes the argument in such a way that it is passed unadulterated by
79 # both FVWM and the shell to a command as a single argument (for use as
80 # an # argument for e.g., the Exec or PipeRead FVWM commands).
81 #
82 # The result must be used with fvwm_cmd_unquoted;
83 sub fvwm_shell_literal {
84         my $s = @_[0] // $_;
85
86         $s =~ s/\$/\$\$/g;
87         if ($s =~ /[' \t]/) {
88                 $s =~ s/'/'\\''/g;
89                 return "'$s'";
90         }
91         $s =~ s/^\s*$/'$&'/;
92         return "$s";
93 }
94
95 # Escapes metacharacters in the argument used in FVWM menu labels.  The
96 # string must still be quoted (e.g., by using fvwm_cmd).
97 sub fvwm_label_escape {
98         my @tokens = split /\t/, $_[0];
99         @tokens[0] =~ s/&/&&/g;
100         my $ret = join "\t", @tokens;
101         $ret =~ s/[\$@%^*]/$&$&/g;
102         return $ret;
103 }
104
105 # make_submenu(name, [args ...])
106 #
107 # Creates a submenu (with the specified name) constructed by invoking this
108 # script with the given arguments.  Returns a list that can be passed to
109 # fvwm_cmd to display the menu.
110 sub make_submenu {
111         my $name = shift;
112         $name =~ s/-/_/g;
113         unshift @_, ("exec", $SELF, "--menu=$name");
114
115         fvwm_cmd("DestroyFunc", "Make$name");
116         fvwm_cmd("AddToFunc", "Make$name");
117         fvwm_cmd("+", "I", "DestroyMenu", $name);
118
119         fvwm_cmd("DestroyMenu", $name);
120         fvwm_cmd("AddToMenu", $name, "DynamicPopupAction", "Make$name");
121         fvwm_cmd("AddToFunc", "KillMenuMPD", "I", "DestroyMenu", $name);
122
123         fvwm_cmd("DestroyFunc", "Make$name");
124         fvwm_cmd("AddToFunc", "Make$name");
125         fvwm_cmd("+", "I", "DestroyMenu", $name);
126         fvwm_cmd("+", "I", "-PipeRead",
127                  join(' ', map { fvwm_shell_literal } @_));
128         fvwm_cmd("AddToFunc", "KillMenuMPD", "I", "DestroyFunc", "Make$name");
129
130         return ("Popup", $name);
131 }
132
133 # get_item_thumbnails({ options }, file, ...)
134 # get_item_thumbnails(file, ...)
135 #
136 # For each music file listed, obtain a thumbnail (if any) for the cover art.
137 #
138 # The first argument is a hash reference to control the mode of operation;
139 # it may be omitted for default options.
140 #
141 #   get_item_thumbnails({ small => 1 }, ...) - smaller thumbnails
142 #
143 # The returned list consists of strings (in the same order as the filename
144 # arguments) suitable for use directly in FVWM menus; by default the filename
145 # is bracketed by asterisks (e.g., "*thumbnail.png*"); in small mode it is
146 # surrounded by % (e.g., "%thumbnail.png%").  If no cover art was found, the
147 # empty string is returned for that file.
148 sub get_item_thumbnails {
149         my @results = ();
150         my $flags = {};
151         my @opts = ();
152
153         $flags = shift if (reftype($_[0]) eq "HASH");
154         return @results unless @_;
155
156         my $c = "*";
157         if ($flags->{small}) {
158                 push @opts, "--small";
159                 $c = "%";
160         }
161
162         open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_;
163         foreach (@_) {
164                 my $thumb = <THUMB>;
165                 chomp $thumb;
166
167                 $thumb = "$c$thumb$c" if (-f $thumb);
168                 push @results, $thumb;
169         }
170         close THUMB;
171         die("mpdthumb failed") if ($?);
172
173         return @results;
174 }
175
176 # add_track_metadata(hashref, key, value)
177 #
178 # Inserts the given key into the referenced hash; if the key already exists
179 # in the hash then the hash element is converted to an array reference (if
180 # it isn't already) and the value is appended to that array.
181 sub add_track_metadata {
182         my ($entry, $key, $value) = @_;
183
184         if (exists($entry->{$key})) {
185                 my $ref = $entry->{$key};
186
187                 if (reftype($ref) ne "ARRAY") {
188                         return if ($ref eq $value);
189
190                         $ref = [$ref];
191                         $entry->{$key} = $ref;
192                 }
193
194                 push(@$ref, $value) unless (any {$_ eq $value} @$ref);
195         } else {
196                 $entry->{$key} = $value;
197         }
198 }
199
200 # get_track_metadata(hashref, key)
201 #
202 # Return the values associated with the given metadata key as a list.
203 sub get_track_metadata {
204         my ($entry, $key) = @_;
205
206         return () unless (exists($entry->{$key}));
207
208         my $ref = $entry->{$key};
209         return @$ref if (reftype($ref) eq "ARRAY");
210         return $ref
211 }
212
213 # Given a music filename, search for the cover art in the same directory.
214 sub mpd_cover_filename {
215         my ($dir) = @_;
216         my $file;
217
218         $dir =~ s/\/[^\/]*$//;
219         foreach ("cover.png", "cover.jpg", "cover.tiff", "cover.bmp") {
220                 if (-f "$dir/$_") {
221                         $file = "$dir/$_";
222                         last;
223                 }
224         }
225         return unless defined $file;
226
227         # Follow one level of symbolic link to get to the scans directory.
228         $file = readlink($file) // $file;
229         $file = "$dir/$file" unless ($file =~ /^\//);
230         return $file;
231 }
232
233 # Generate the cover art entry in the top menu.
234 sub top_track_cover {
235         my ($entry) = @_;
236
237         ($entry->{thumb}) = get_item_thumbnails($entry->{file});
238         print "$entry->{thumb}\n";
239         if ($entry->{thumb}) {
240                 my $file = "$MUSIC/$entry->{file}";
241                 my $cover = mpd_cover_filename($file);
242
243                 $cover = fvwm_shell_literal($cover // $file);
244                 fvwm_cmd_unquoted("AddToMenu", escape($menu),
245                                   escape($entry->{thumb}),
246                                   "Exec", "exec", "geeqie", $cover);
247         }
248 }
249
250 # Generate the "Title:" entry in the top menu.
251 sub top_track_title {
252         my ($entry) = @_;
253
254         my @submenu = make_submenu("$menu-TopTrack",
255                                    "--title=$entry->{Title}");
256
257         fvwm_cmd("AddToMenu", $menu,
258                  fvwm_label_escape("Title:\t$entry->{Title}"),
259                  @submenu);
260 }
261
262 # Generate the "Artist:" entry in the top menu.
263 sub top_track_artist {
264         my ($entry) = @_;
265
266         my @submenu = make_submenu("$menu-TopArtist",
267                                    "--artist=$entry->{Artist}");
268
269         fvwm_cmd("AddToMenu", $menu,
270                  fvwm_label_escape("Artist:\t$entry->{Artist}"),
271                  @submenu);
272 }
273
274 # Generate the "Album:" entry in the top menu.
275 sub top_track_album {
276         my ($entry) = @_;
277         my @submenu;
278
279         my @submenu = make_submenu("$menu-TopAlbum",
280                                    "--artist=$entry->{Artist}",
281                                    "--album=$entry->{Album}");
282
283         fvwm_cmd("AddToMenu", $menu,
284                  fvwm_label_escape("Album:\t$entry->{Album}"),
285                  @submenu);
286 }
287
288 # Global hash for tracking what is to be "accepted".
289 my %accept = ();
290
291 # Default values for stuff.
292 my ($album, $artist, $title);
293
294 sub print_version {
295         print <<EOF
296 mpdmenu.pl 0.8
297 Copyright © 2019 Nick Bowler
298 License GPLv3+: GNU General Public License version 3 or any later version.
299 This is free software: you are free to change and redistribute it.
300 There is NO WARRANTY, to the extent permitted by law.
301 EOF
302 }
303
304 sub print_usage {
305         my $fh = $_[1] // *STDERR;
306
307         print $fh "Usage: $0 [options]\n";
308         print "Try $0 --help for more information.\n" unless (@_ > 0);
309 }
310
311 sub print_help {
312         print_usage(*STDOUT);
313         print <<EOF
314 This is "mpdmenu": a menu-based MPD client for FVWM.
315
316 Options:
317   -h, --host=HOST   Connect to the MPD server on HOST, overriding defaults.
318   -p, --port=PORT   Connect to the MPD server on PORT, overriding defaults.
319   -m, --menu=NAME   Set the name of the generated menu.
320   -V, --version     Print a version message and then exit.
321   -H, --help        Print this message and then exit.
322 EOF
323 }
324
325 GetOptions(
326         'host|h=s'   => \$host,
327         'port|p=s'   => \$port,
328         'menu|m=s'   => \$menu,
329
330         'album=s'    => sub { $album = $_[1]; $mode = "album"; },
331         'artist=s'   => sub { $artist = $_[1];
332                               $mode = "artist" unless $mode eq "album"; },
333         'title=s'    => sub { $title = $_[1]; $mode = "track"; },
334
335         'V|version'  => sub { print_version(); exit },
336         'H|help'     => sub { print_help(); exit },
337 ) or do { print_usage; exit 1 };
338
339 # Connect to MPD.
340 $sock = new IO::Socket::INET6(
341         PeerAddr => $host,
342         PeerPort => $port,
343         Proto => 'tcp',
344         Timeout => 2
345 ) or die("could not open socket: $!.\n");
346 binmode($sock, ":utf8");
347
348 die("could not connect to MPD: $!.\n")
349         if (!(<$sock> =~ /^OK MPD ([0-9]+)\.([0-9]+)\.([0-9]+)$/));
350
351 die("MPD version $1.$2.$3 insufficient.\n")
352         if (  ($1 <  MPD_MJR_MIN)
353            || ($1 == MPD_MJR_MIN && $2 <  MPD_MNR_MIN)
354            || ($1 == MPD_MJR_MIN && $2 == MPD_MNR_MIN && $3 < MPD_REV_MIN));
355
356 if ($mode eq "top") {
357         my %current;
358         my %state;
359
360         $menu //= "MenuMPD";
361
362         mpd_exec("status");
363         while (<$sock>) {
364                 last if (/^OK/);
365                 die($_) if (/^ACK/);
366
367                 if (/^(\w+): (.*)$/) {
368                         $state{$1} = $2;
369                 }
370         }
371
372         mpd_exec("currentsong");
373         while (<$sock>) {
374                 last if (/^OK/);
375                 die($_) if (/^ACK/);
376
377                 if (/^(\w+): (.*)$/) {
378                         add_track_metadata(\%current, $1, $2);
379                 }
380         }
381
382         my $playstate = $state{state} eq "play"  ? "Playing"
383                       : $state{state} eq "stop"  ? "Stopped"
384                       : $state{state} eq "pause" ? "Paused"
385                       : "Unknown";
386         fvwm_cmd("AddToMenu", $menu, $playstate, "Title");
387
388         if (exists($current{file})) {
389                 top_track_cover(\%current);
390                 top_track_title(\%current);
391                 top_track_artist(\%current);
392                 top_track_album(\%current);
393         } else {
394                 fvwm_cmd("AddToMenu", $menu, "[current track unavailable]");
395         }
396
397         if ($state{state} =~ /^p/) {
398                 my $pp = $state{state} eq "pause" ? "lay" : "ause";
399
400                 fvwm_cmd("AddToMenu", $menu, "", "Nop");
401                 fvwm_cmd("AddToMenu", $menu, "Next%next.svg:16x16%",
402                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "next");
403                 fvwm_cmd("AddToMenu", $menu, "P$pp%p$pp.svg:16x16%",
404                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "p$pp");
405                 fvwm_cmd("AddToMenu", $menu, "Stop%stop.svg:16x16%",
406                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "stop");
407                 fvwm_cmd("AddToMenu", $menu, "Prev%prev.svg:16x16%",
408                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "previous");
409         } elsif ($state{state} eq "stop") {
410                 fvwm_cmd("AddToMenu", $menu, "", "Nop");
411                 fvwm_cmd("AddToMenu", $menu, "Play%play.svg:16x16%",
412                        "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "play");
413         }
414 } elsif ($mode eq "album") {
415         # Create an album menu.
416         my @playlist = ();
417         my $entry;
418
419         $menu = "MenuMPDAlbum" unless defined $menu;
420
421         $album =~ s/"/\\"/g;
422         print $sock "playlistfind album \"$album\"\n";
423         while (<$sock>) {
424                 last if (/^OK/);
425                 die($_) if (/^ACK/);
426
427                 if (/^(\w+): (.*)$/) {
428                         if ($1 eq "file") {
429                                 if (keys(%$entry) > 0) {
430                                         addalbumentry(\@playlist, $entry)
431                                 }
432
433                                 $entry = {};
434                         }
435
436                         $entry->{$1} = $2;
437                 }
438         }
439         addalbumentry(\@playlist, $entry) if (keys(%$entry) > 0);
440
441         die("No tracks found.\n") if (!@playlist);
442         foreach (sort albumsort @playlist) {
443                 my ($t_file, $t_trackno, $t_artist, $t_title, $t_id) = (
444                         $_->{file},
445                         $_->{Track},
446                         $_->{Artist},
447                         $_->{Title},
448                         $_->{Id},
449                 );
450
451                 next if (defined $artist && !$accept{albumdir($t_file)});
452
453                 $t_artist = sanitise($t_artist, 0);
454                 $t_title  = sanitise($t_title, 0);
455
456                 my $cmd = sprintf "AddToMenu $menu \"%d\t%s - %s\""
457                                   ." Exec exec $FindBin::Bin/mpdexec.pl"
458                                   ." playid %d",
459                                   $t_trackno, $t_artist, $t_title, $t_id;
460
461                 cmd($cmd);
462         }
463 } elsif ($mode eq "artist") {
464         # Create an artist menu.
465         my %albums = ();
466         my $file;
467         my $quoteartist = $artist;
468
469         $menu = "MenuMPDArtist" unless defined $menu;
470
471         $quoteartist =~ s/"/\\"/g;
472         print $sock "playlistfind artist \"$quoteartist\"\n";
473         while (<$sock>) {
474                 last if (/^OK/);
475                 die($_) if (/^ACK/);
476
477                 if (/^(\w+): (.*)$/) {
478                         $file       = $2    if ($1 eq "file");
479                         $albums{$2} = $file if ($1 eq "Album");
480                 }
481         }
482
483         die("No albums found.\n") if (!keys(%albums));
484
485 { # work around 'use locale' breaking s///i
486         my $i = 0;
487         use locale;
488
489         my @keys = sort keys %albums;
490         my @thumbs = get_item_thumbnails({ small => 1 },
491                                           map { $albums{$_} } @keys);
492
493         foreach my $key (@keys) {
494                 my $a_album  = sanitise($key, 1);
495                 my $thumb = shift @thumbs;
496
497                 cmd("AddToMenu $menu \"$thumb$a_album\" Popup MenuMPDArt_$i");
498
499                 cmd("AddToMenu MenuMPDArt_$i DynamicPopUpAction MakeMenuMPDArt_$i");
500
501                 cmd("DestroyFunc MakeMenuMPDArt_$i");
502                 cmd("AddToFunc   MakeMenuMPDArt_$i
503                      + I DestroyMenu MenuMPDArt_$i
504                      + I -PipeRead \"exec $SELF "
505                            ."--menu MenuMPDArt_$i "
506                            ."--album  ".shellify($key, 1)." "
507                            ."--artist ".shellify($artist, 1)."\"");
508
509                 cmd("AddToFunc KillMenuMPD I DestroyMenu MenuMPDArt_$i");
510                 cmd("AddToFunc KillMenuMPD I DestroyFunc MakeMenuMPDArt_$i");
511
512                 $i++;
513         }
514 } # end use locale workaround
515 } elsif ($mode eq "track") {
516         # Create a title menu.
517         my @titles;
518         my $entry;
519
520         $menu = "MenuMPDTitle" unless defined $menu;
521
522         # Open and close brackets.
523         my ($ob, $cb) = ("[\[~〜<〈(ー−-]", "[\]~〜>〉)ー−-]");
524
525         $_ = $title;
526
527         # Deal with specific cases.
528         s/ちいさな(?=ヘミソフィア)//;                 # ヘミソフィア
529         s/ "mix on air flavor" dear EIKO SHIMAMIYA//; # Spiral wind
530         s/ "So,you need me" Style//;                  # I need you
531         s/ ::Symphony Second movement:://;            # Disintegration
532         s/-\[instrumental\]//;                        # 青い果実
533         s/ -Practice Track-//;                        # Fair Heaven
534         s/〜世界で一番アナタが好き〜//;               # Pure Heart
535         s/〜彼方への哀歌//;                           # 十二幻夢
536         s/ sora no uta ver.//;                       # 美しい星
537
538         s/\s*-remix-$//; # Otherwise "D-THREAD -remix-" doesn't work right.
539
540         # Deal with titles like "blah (ABC version)".
541         s/\s*$ob.*(style|mix|edit|edition|ver\.?|version|melody|カラオケ)$cb?$//i;
542
543         # Deal with titles like "blah (without XYZ)".
544         s/\s*$ob\s*((e\.)?piano|english|japanese|inst|tv|without|w\/o|off|back|short|karaoke|game).*//i;
545
546         # Deal with titles like "blah instrumental".
547         s/\s+(instrumental|off vocal|short|tv)([\s-]+(mix|size|version))?$//i;
548         s/\s+without\s+\w+$//i;
549
550         # Deal with separate movements in classical pieces.
551         s/: [IVX]+\..*//;
552
553         my $basetitle  = $_;
554         my $_basetitle = $basetitle;
555
556         $_basetitle =~ s/"/\\"/g;
557         print $sock "playlistsearch title \"$_basetitle\"\n";
558         while (<$sock>) {
559                 last if (/^OK/);
560                 die($_) if (/^ACK/);
561
562                 if (/^(\w+): (.*)$/) {
563                         if ($1 eq "file") {
564                                 push @titles, $entry if (keys(%$entry) > 0);
565                                 $entry = {};
566                         }
567
568                         $entry->{$1} = $2;
569                 }
570         }
571         push @titles, $entry if (keys(%$entry) > 0);
572
573 { # work around 'use locale' breaking s///i
574         use locale;
575
576         my @thumbs = get_item_thumbnails({ small => 1 },
577                                           map { $_->{file} } @titles);
578         for (my $i = 0; $i < @titles; $i++) {
579                 $titles[$i]->{thumb} = $thumbs[$i];
580         }
581
582         foreach (sort titlesort @titles) {
583                 my ($t_file, $t_artist, $t_title, $t_id, $thumb) = (
584                         $_->{file},
585                         $_->{Artist},
586                         $_->{Title},
587                         $_->{Id},
588                         $_->{thumb}
589                 );
590
591                 # MPD searches are case-insensitive.
592                 next if (!($t_title =~ m/(\P{Latin}|^)\Q$basetitle\E(\P{Latin}|$)/ || $t_title =~ m/\Q$basetitle\E/i));
593
594                 $t_artist = sanitise($t_artist, 1);
595                 $t_title  = sanitise($t_title, 1);
596
597                 cmd("AddToMenu $menu \"$thumb$t_artist - $t_title\""
598                     ." Exec exec $FindBin::Bin/mpdexec.pl"
599                     ." playid $t_id");
600         }
601 } # end use locale workaround
602 }
603
604 # Finished.
605 print $sock "close\n";
606
607 sub sanitise
608 {
609         $_ = $_[0];
610         s/&/&&/g if ($_[1]);
611         s/([\$@%^*])/\1\1/g;
612         s/"/\\"/g;
613         return $_;
614 }
615
616 sub addalbumentry
617 {
618         my ($playlist, $entry) = @_;
619
620         push(@$playlist, $entry);
621
622         if (defined $artist && $artist eq $entry->{Artist}) {
623                 my $albumdir = albumdir($entry->{file});
624                 $accept{$albumdir} = 1;
625         }
626 }
627
628 sub albumdir
629 {
630         my $file = $_[0];
631
632         $file =~ s:(/Disk [0-9]+[^/]*)?/[^/]*$::;
633         return $file
634 }
635
636 sub albumsort
637 {
638         return ($a->{Disc} <=> $b->{Disc}) if ($a->{Disc} != $b->{Disc});
639         return ($a->{Track} <=> $b->{Track});
640 }
641
642 sub titlesort
643 {
644         return ($a->{Album}  cmp $b->{Album})  if($a->{Album}  ne $b->{Album});
645         return ($a->{Artist} cmp $b->{Artist}) if($a->{Artist} ne $b->{Artist});
646         return ($a->{Title}  cmp $b->{Title});
647 }
648
649 sub shellify
650 {
651         my ($str, $quoted) = @_;
652         $str =~ s/'/'\\''/g;
653         if ($quoted) {
654                 $str =~ s/\\/\\\\/g;
655                 $str =~ s/"/\\"/g;
656         }
657         return "'$str'";
658 }
659
660 sub cmd
661 {
662         print "$_[0]\n";
663 }