3 # Copyright © 2008,2010,2012,2019 Nick Bowler
5 # Silly little script to generate an FVWM menu with various bits of MPD
6 # status information and controls.
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.
16 use Encode qw(decode encode);
17 use Encode::Locale qw(decode_argv);
18 decode_argv(Encode::FB_CROAK);
19 binmode(STDOUT, ":utf8");
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);
33 my $SELF = "$FindBin::Bin/$FindBin::Script";
35 my $MUSIC = $ENV{MUSIC} // "/srv/music";
36 my $host = $ENV{MPD_HOST} // "localhost";
37 my $port = $ENV{MPD_PORT} // "6600";
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.
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.
62 # Submit a command to the MPD server; each argument to this function
63 # is quoted and sent as a single argument to MPD.
65 my $cmd = join(' ', map { escape } @_);
70 sub fvwm_cmd_unquoted {
71 print join(' ', @_), "\n";
75 fvwm_cmd_unquoted(map { escape } @_);
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).
82 # The result must be used with fvwm_cmd_unquoted;
83 sub fvwm_shell_literal {
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;
105 # make_submenu(name, [args ...])
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.
113 unshift @_, ("exec", $SELF, "--menu=$name");
115 fvwm_cmd("DestroyFunc", "Make$name");
116 fvwm_cmd("AddToFunc", "Make$name");
117 fvwm_cmd("+", "I", "DestroyMenu", $name);
119 fvwm_cmd("DestroyMenu", $name);
120 fvwm_cmd("AddToMenu", $name, "DynamicPopupAction", "Make$name");
121 fvwm_cmd("AddToFunc", "KillMenuMPD", "I", "DestroyMenu", $name);
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");
130 return ("Popup", $name);
133 # get_item_thumbnails({ options }, file, ...)
134 # get_item_thumbnails(file, ...)
136 # For each music file listed, obtain a thumbnail (if any) for the cover art.
138 # The first argument is a hash reference to control the mode of operation;
139 # it may be omitted for default options.
141 # get_item_thumbnails({ small => 1 }, ...) - smaller thumbnails
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 {
153 $flags = shift if (reftype($_[0]) eq "HASH");
154 return @results unless @_;
157 if ($flags->{small}) {
158 push @opts, "--small";
162 open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_;
167 $thumb = "$c$thumb$c" if (-f $thumb);
168 push @results, $thumb;
171 die("mpdthumb failed") if ($?);
176 # add_track_metadata(hashref, key, value)
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) = @_;
184 if (exists($entry->{$key})) {
185 my $ref = $entry->{$key};
187 if (reftype($ref) ne "ARRAY") {
188 return if ($ref eq $value);
191 $entry->{$key} = $ref;
194 push(@$ref, $value) unless (any {$_ eq $value} @$ref);
196 $entry->{$key} = $value;
200 # get_track_metadata(hashref, key)
202 # Return the values associated with the given metadata key as a list.
203 sub get_track_metadata {
204 my ($entry, $key) = @_;
206 return () unless (exists($entry->{$key}));
208 my $ref = $entry->{$key};
209 return @$ref if (reftype($ref) eq "ARRAY");
213 # Given a music filename, search for the cover art in the same directory.
214 sub mpd_cover_filename {
218 $dir =~ s/\/[^\/]*$//;
219 foreach ("cover.png", "cover.jpg", "cover.tiff", "cover.bmp") {
225 return unless defined $file;
227 # Follow one level of symbolic link to get to the scans directory.
228 $file = readlink($file) // $file;
229 $file = "$dir/$file" unless ($file =~ /^\//);
233 # Generate the cover art entry in the top menu.
234 sub top_track_cover {
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);
243 $cover = fvwm_shell_literal($cover // $file);
244 fvwm_cmd_unquoted("AddToMenu", escape($menu),
245 escape($entry->{thumb}),
246 "Exec", "exec", "geeqie", $cover);
250 # Generate the "Title:" entry in the top menu.
251 sub top_track_title {
254 my @submenu = make_submenu("$menu-TopTrack",
255 "--title=$entry->{Title}");
257 fvwm_cmd("AddToMenu", $menu,
258 fvwm_label_escape("Title:\t$entry->{Title}"),
262 # Generate the "Artist:" entry in the top menu.
263 sub top_track_artist {
266 my @submenu = make_submenu("$menu-TopArtist",
267 "--artist=$entry->{Artist}");
269 fvwm_cmd("AddToMenu", $menu,
270 fvwm_label_escape("Artist:\t$entry->{Artist}"),
274 # Generate the "Album:" entry in the top menu.
275 sub top_track_album {
279 my @submenu = make_submenu("$menu-TopAlbum",
280 "--artist=$entry->{Artist}",
281 "--album=$entry->{Album}");
283 fvwm_cmd("AddToMenu", $menu,
284 fvwm_label_escape("Album:\t$entry->{Album}"),
288 # Global hash for tracking what is to be "accepted".
291 # Default values for stuff.
292 my ($album, $artist, $title);
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.
305 my $fh = $_[1] // *STDERR;
307 print $fh "Usage: $0 [options]\n";
308 print "Try $0 --help for more information.\n" unless (@_ > 0);
312 print_usage(*STDOUT);
314 This is "mpdmenu": a menu-based MPD client for FVWM.
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.
326 'host|h=s' => \$host,
327 'port|p=s' => \$port,
328 'menu|m=s' => \$menu,
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"; },
335 'V|version' => sub { print_version(); exit },
336 'H|help' => sub { print_help(); exit },
337 ) or do { print_usage; exit 1 };
340 $sock = new IO::Socket::INET6(
345 ) or die("could not open socket: $!.\n");
346 binmode($sock, ":utf8");
348 die("could not connect to MPD: $!.\n")
349 if (!(<$sock> =~ /^OK MPD ([0-9]+)\.([0-9]+)\.([0-9]+)$/));
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));
356 if ($mode eq "top") {
367 if (/^(\w+): (.*)$/) {
372 mpd_exec("currentsong");
377 if (/^(\w+): (.*)$/) {
378 add_track_metadata(\%current, $1, $2);
382 my $playstate = $state{state} eq "play" ? "Playing"
383 : $state{state} eq "stop" ? "Stopped"
384 : $state{state} eq "pause" ? "Paused"
386 fvwm_cmd("AddToMenu", $menu, $playstate, "Title");
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);
394 fvwm_cmd("AddToMenu", $menu, "[current track unavailable]");
397 if ($state{state} =~ /^p/) {
398 my $pp = $state{state} eq "pause" ? "lay" : "ause";
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");
414 } elsif ($mode eq "album") {
415 # Create an album menu.
419 $menu = "MenuMPDAlbum" unless defined $menu;
422 print $sock "playlistfind album \"$album\"\n";
427 if (/^(\w+): (.*)$/) {
429 if (keys(%$entry) > 0) {
430 addalbumentry(\@playlist, $entry)
439 addalbumentry(\@playlist, $entry) if (keys(%$entry) > 0);
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) = (
451 next if (defined $artist && !$accept{albumdir($t_file)});
453 $t_artist = sanitise($t_artist, 0);
454 $t_title = sanitise($t_title, 0);
456 my $cmd = sprintf "AddToMenu $menu \"%d\t%s - %s\""
457 ." Exec exec $FindBin::Bin/mpdexec.pl"
459 $t_trackno, $t_artist, $t_title, $t_id;
463 } elsif ($mode eq "artist") {
464 # Create an artist menu.
467 my $quoteartist = $artist;
469 $menu = "MenuMPDArtist" unless defined $menu;
471 $quoteartist =~ s/"/\\"/g;
472 print $sock "playlistfind artist \"$quoteartist\"\n";
477 if (/^(\w+): (.*)$/) {
478 $file = $2 if ($1 eq "file");
479 $albums{$2} = $file if ($1 eq "Album");
483 die("No albums found.\n") if (!keys(%albums));
485 { # work around 'use locale' breaking s///i
489 my @keys = sort keys %albums;
490 my @thumbs = get_item_thumbnails({ small => 1 },
491 map { $albums{$_} } @keys);
493 foreach my $key (@keys) {
494 my $a_album = sanitise($key, 1);
495 my $thumb = shift @thumbs;
497 cmd("AddToMenu $menu \"$thumb$a_album\" Popup MenuMPDArt_$i");
499 cmd("AddToMenu MenuMPDArt_$i DynamicPopUpAction MakeMenuMPDArt_$i");
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)."\"");
509 cmd("AddToFunc KillMenuMPD I DestroyMenu MenuMPDArt_$i");
510 cmd("AddToFunc KillMenuMPD I DestroyFunc MakeMenuMPDArt_$i");
514 } # end use locale workaround
515 } elsif ($mode eq "track") {
516 # Create a title menu.
520 $menu = "MenuMPDTitle" unless defined $menu;
522 # Open and close brackets.
523 my ($ob, $cb) = ("[\[~〜<〈(ー−-]", "[\]~〜>〉)ー−-]");
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
536 s/ sora no uta ver.//; # 美しい星
538 s/\s*-remix-$//; # Otherwise "D-THREAD -remix-" doesn't work right.
540 # Deal with titles like "blah (ABC version)".
541 s/\s*$ob.*(style|mix|edit|edition|ver\.?|version|melody|カラオケ)$cb?$//i;
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;
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;
550 # Deal with separate movements in classical pieces.
554 my $_basetitle = $basetitle;
556 $_basetitle =~ s/"/\\"/g;
557 print $sock "playlistsearch title \"$_basetitle\"\n";
562 if (/^(\w+): (.*)$/) {
564 push @titles, $entry if (keys(%$entry) > 0);
571 push @titles, $entry if (keys(%$entry) > 0);
573 { # work around 'use locale' breaking s///i
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];
582 foreach (sort titlesort @titles) {
583 my ($t_file, $t_artist, $t_title, $t_id, $thumb) = (
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));
594 $t_artist = sanitise($t_artist, 1);
595 $t_title = sanitise($t_title, 1);
597 cmd("AddToMenu $menu \"$thumb$t_artist - $t_title\""
598 ." Exec exec $FindBin::Bin/mpdexec.pl"
601 } # end use locale workaround
605 print $sock "close\n";
618 my ($playlist, $entry) = @_;
620 push(@$playlist, $entry);
622 if (defined $artist && $artist eq $entry->{Artist}) {
623 my $albumdir = albumdir($entry->{file});
624 $accept{$albumdir} = 1;
632 $file =~ s:(/Disk [0-9]+[^/]*)?/[^/]*$::;
638 return ($a->{Disc} <=> $b->{Disc}) if ($a->{Disc} != $b->{Disc});
639 return ($a->{Track} <=> $b->{Track});
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});
651 my ($str, $quoted) = @_;