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 max);
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";
40 my ($albumid, $artist, $title);
44 # Quotes the argument so that it is presented as a single argument to MPD
45 # at the protocol level. This also works OK for most FVWM arguments.
49 # No way to encode literal newlines in the protocol, so we
50 # convert any newlines in the arguments into a space, which
51 # can help with quoting.
63 # Submit a command to the MPD server; each argument to this function
64 # is quoted and sent as a single argument to MPD.
66 my $cmd = join(' ', map { escape } @_);
71 sub fvwm_cmd_unquoted {
72 print join(' ', @_), "\n";
76 fvwm_cmd_unquoted(map { escape } @_);
79 # Quotes the argument in such a way that it is passed unadulterated by
80 # both FVWM and the shell to a command as a single argument (for use as
81 # an # argument for e.g., the Exec or PipeRead FVWM commands).
83 # The result must be used with fvwm_cmd_unquoted;
84 sub fvwm_shell_literal {
96 # Escapes metacharacters in the argument used in FVWM menu labels. The
97 # string must still be quoted (e.g., by using fvwm_cmd).
98 sub fvwm_label_escape {
99 my @tokens = split /\t/, $_[0];
100 @tokens[0] =~ s/&/&&/g;
101 my $ret = join "\t", @tokens;
102 $ret =~ s/[\$@%^*]/$&$&/g;
106 # make_submenu(name, [args ...])
108 # Creates a submenu (with the specified name) constructed by invoking this
109 # script with the given arguments. Returns a list that can be passed to
110 # fvwm_cmd to display the menu.
114 unshift @_, ("exec", $SELF, "--menu=$name");
116 fvwm_cmd("DestroyFunc", "Make$name");
117 fvwm_cmd("AddToFunc", "Make$name");
118 fvwm_cmd("+", "I", "DestroyMenu", $name);
120 fvwm_cmd("DestroyMenu", $name);
121 fvwm_cmd("AddToMenu", $name, "DynamicPopupAction", "Make$name");
122 fvwm_cmd("AddToFunc", "KillMenuMPD", "I", "DestroyMenu", $name);
124 fvwm_cmd("DestroyFunc", "Make$name");
125 fvwm_cmd("AddToFunc", "Make$name");
126 fvwm_cmd("+", "I", "DestroyMenu", $name);
127 fvwm_cmd("+", "I", "-PipeRead",
128 join(' ', map { fvwm_shell_literal } @_));
129 fvwm_cmd("AddToFunc", "KillMenuMPD", "I", "DestroyFunc", "Make$name");
131 return ("Popup", $name);
134 # get_item_thumbnails({ options }, file, ...)
135 # get_item_thumbnails(file, ...)
137 # For each music file listed, obtain a thumbnail (if any) for the cover art.
139 # The first argument is a hash reference to control the mode of operation;
140 # it may be omitted for default options.
142 # get_item_thumbnails({ small => 1 }, ...) - smaller thumbnails
144 # The returned list consists of strings (in the same order as the filename
145 # arguments) suitable for use directly in FVWM menus; by default the filename
146 # is bracketed by asterisks (e.g., "*thumbnail.png*"); in small mode it is
147 # surrounded by % (e.g., "%thumbnail.png%"). If no cover art was found, the
148 # empty string is returned for that file.
149 sub get_item_thumbnails {
154 $flags = shift if (reftype($_[0]) eq "HASH");
155 return @results unless @_;
158 if ($flags->{small}) {
159 push @opts, "--small";
163 open THUMB, "-|", "$FindBin::Bin/mpdthumb.sh", @opts, "--", @_;
168 $thumb = "$c$thumb$c" if (-f $thumb);
169 push @results, $thumb;
172 die("mpdthumb failed") if ($?);
177 # add_track_metadata(hashref, key, value)
179 # Inserts the given key into the referenced hash; if the key already exists
180 # in the hash then the hash element is converted to an array reference (if
181 # it isn't already) and the value is appended to that array.
182 sub add_track_metadata {
183 my ($entry, $key, $value) = @_;
185 if (exists($entry->{$key})) {
186 my $ref = $entry->{$key};
188 if (reftype($ref) ne "ARRAY") {
189 return if ($ref eq $value);
192 $entry->{$key} = $ref;
195 push(@$ref, $value) unless (any {$_ eq $value} @$ref);
197 $entry->{$key} = $value;
201 # get_track_metadata(hashref, key)
203 # Return the values associated with the given metadata key as a list.
204 sub get_track_metadata {
205 my ($entry, $key) = @_;
207 return () unless (exists($entry->{$key}));
209 my $ref = $entry->{$key};
210 return @$ref if (reftype($ref) eq "ARRAY");
214 # Given a music filename, search for the cover art in the same directory.
215 sub mpd_cover_filename {
219 $dir =~ s/\/[^\/]*$//;
220 foreach ("cover.png", "cover.jpg", "cover.tiff", "cover.bmp") {
226 return unless defined $file;
228 # Follow one level of symbolic link to get to the scans directory.
229 $file = readlink($file) // $file;
230 $file = "$dir/$file" unless ($file =~ /^\//);
234 # Generate the cover art entry in the top menu.
235 sub top_track_cover {
238 ($entry->{thumb}) = get_item_thumbnails($entry->{file});
239 print "$entry->{thumb}\n";
240 if ($entry->{thumb}) {
241 my $file = "$MUSIC/$entry->{file}";
242 my $cover = mpd_cover_filename($file);
244 $cover = fvwm_shell_literal($cover // $file);
245 fvwm_cmd_unquoted("AddToMenu", escape($menu),
246 escape($entry->{thumb}),
247 "Exec", "exec", "geeqie", $cover);
251 # Generate the "Title:" entry in the top menu.
252 sub top_track_title {
255 my @submenu = make_submenu("$menu-TopTrack",
256 "--title=$entry->{Title}");
258 fvwm_cmd("AddToMenu", $menu,
259 fvwm_label_escape("Title:\t$entry->{Title}"),
263 # Generate the "Artist:" entry in the top menu.
264 sub top_track_artist {
267 my @submenu = make_submenu("$menu-TopArtist",
268 "--artist=$entry->{Artist}");
270 fvwm_cmd("AddToMenu", $menu,
271 fvwm_label_escape("Artist:\t$entry->{Artist}"),
275 # Generate the "Album:" entry in the top menu.
276 sub top_track_album {
280 my ($mbid) = get_track_metadata($entry, "MUSICBRAINZ_ALBUMID");
281 @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid") if $mbid;
283 fvwm_cmd("AddToMenu", $menu,
284 fvwm_label_escape("Album:\t$entry->{Album}"),
288 # Given a release MBID, return a hash reference containing all its
289 # associated tracks in the MPD database. The hash keys are filenames.
290 sub get_tracks_by_release_mbid {
295 return \%matches unless ($mbid);
296 mpd_exec("search", "(MUSICBRAINZ_ALBUMID == \"$mbid\")");
301 if (/^(\w+): (.*)$/) {
303 if (exists($matches{$2})) {
304 $entry = $matches{$2};
307 $matches{$2} = $entry;
311 add_track_metadata($entry, $1, $2);
318 # Given a filename, return the IDs (if any) for that file in the
319 # current MPD play queue.
320 sub get_ids_by_filename {
324 mpd_exec("playlistfind", "file", $file);
329 if (/^(\w+): (.*)$/) {
330 push @results, $2 if ($1 eq "Id");
337 # albumsort(matches, a, b)
339 # Sort hash keys (a, b) by disc/track number for album menus.
341 my ($matches, $a, $b) = @_;
343 return $matches->{$a}->{Disc} <=> $matches->{$b}->{Disc}
344 || $matches->{$a}->{Track} <=> $matches->{$b}->{Track}
348 # datesort(matches, a, b)
350 # Sort hash keys (a, b) by release date
352 my ($matches, $a, $b) = @_;
354 return $matches->{$a}->{Date} cmp $matches->{$b}->{Date}
358 # menu_trackname(entry)
360 # Format the track name for display in an FVWM menu, where entry
361 # is a hash reference containing the track metadata.
364 my $fmt = "$entry->{trackfmt}$entry->{Artist} - $entry->{Title}";
365 return "$entry->{thumb}" . fvwm_label_escape($fmt);
371 Copyright © 2019 Nick Bowler
372 License GPLv3+: GNU General Public License version 3 or any later version.
373 This is free software: you are free to change and redistribute it.
374 There is NO WARRANTY, to the extent permitted by law.
379 my $fh = $_[1] // *STDERR;
381 print $fh "Usage: $0 [options]\n";
382 print "Try $0 --help for more information.\n" unless (@_ > 0);
386 print_usage(*STDOUT);
388 This is "mpdmenu": a menu-based MPD client for FVWM.
391 -h, --host=HOST Connect to the MPD server on HOST, overriding defaults.
392 -p, --port=PORT Connect to the MPD server on PORT, overriding defaults.
393 -m, --menu=NAME Set the name of the generated menu.
394 --album-id=MBID Generate a menu for the given release MBID.
395 -V, --version Print a version message and then exit.
396 -H, --help Print this message and then exit.
401 'host|h=s' => \$host,
402 'port|p=s' => \$port,
403 'menu|m=s' => \$menu,
405 'album-id=s' => sub { $albumid = $_[1]; $mode = "album"; },
406 'artist=s' => sub { $artist = $_[1]; $mode = "artist"; },
407 'title=s' => sub { $title = $_[1]; $mode = "track"; },
409 'V|version' => sub { print_version(); exit },
410 'H|help' => sub { print_help(); exit },
411 ) or do { print_usage; exit 1 };
414 $sock = new IO::Socket::INET6(
419 ) or die("could not open socket: $!.\n");
420 binmode($sock, ":utf8");
422 die("could not connect to MPD: $!.\n")
423 if (!(<$sock> =~ /^OK MPD ([0-9]+)\.([0-9]+)\.([0-9]+)$/));
425 die("MPD version $1.$2.$3 insufficient.\n")
426 if ( ($1 < MPD_MJR_MIN)
427 || ($1 == MPD_MJR_MIN && $2 < MPD_MNR_MIN)
428 || ($1 == MPD_MJR_MIN && $2 == MPD_MNR_MIN && $3 < MPD_REV_MIN));
430 if ($mode eq "top") {
441 if (/^(\w+): (.*)$/) {
446 mpd_exec("currentsong");
451 if (/^(\w+): (.*)$/) {
452 add_track_metadata(\%current, $1, $2);
456 my $playstate = $state{state} eq "play" ? "Playing"
457 : $state{state} eq "stop" ? "Stopped"
458 : $state{state} eq "pause" ? "Paused"
460 fvwm_cmd("AddToMenu", $menu, $playstate, "Title");
462 if (exists($current{file})) {
463 top_track_cover(\%current);
464 top_track_title(\%current);
465 top_track_artist(\%current);
466 top_track_album(\%current);
468 fvwm_cmd("AddToMenu", $menu, "[current track unavailable]");
471 if ($state{state} =~ /^p/) {
472 my $pp = $state{state} eq "pause" ? "lay" : "ause";
474 fvwm_cmd("AddToMenu", $menu, "", "Nop");
475 fvwm_cmd("AddToMenu", $menu, "Next%next.svg:16x16%",
476 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "next");
477 fvwm_cmd("AddToMenu", $menu, "P$pp%p$pp.svg:16x16%",
478 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "p$pp");
479 fvwm_cmd("AddToMenu", $menu, "Stop%stop.svg:16x16%",
480 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "stop");
481 fvwm_cmd("AddToMenu", $menu, "Prev%prev.svg:16x16%",
482 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "previous");
483 } elsif ($state{state} eq "stop") {
484 fvwm_cmd("AddToMenu", $menu, "", "Nop");
485 fvwm_cmd("AddToMenu", $menu, "Play%play.svg:16x16%",
486 "Exec", "exec", "$FindBin::Bin/mpdexec.pl", "play");
488 } elsif ($mode eq "album") {
489 my $matches = get_tracks_by_release_mbid($albumid);
492 $menu //= "MenuMPDAlbum";
494 my $track_max = max(map { $_->{Track} } values %$matches);
495 my $disc_max = max(map { $_->{Disc} } values %$matches);
497 # CDs have a max of 99 tracks and I hope 100+-disc-releases
498 # don't exist so this is fine.
499 my $track_digits = $track_max >= 10 ? 2 : 1;
500 my $disc_digits = $disc_max > 1 ? $disc_max >= 10 ? 2 : 1 : 0;
503 fvwm_cmd("AddToMenu", $menu);
504 fvwm_cmd("+", "Release not found", "Title") unless keys %$matches;
505 foreach my $file (sort { albumsort($matches, $a, $b) } keys %$matches) {
506 my $entry = $matches->{$file};
508 # Format disc/track numbers
509 $entry->{trackfmt} = sprintf("%*.*s%.*s%*d\t",
510 $disc_digits, $disc_digits, $entry->{Disc},
512 $track_digits, $entry->{Track});
513 $entry->{trackfmt} =~ s/ /\N{U+2007}/g;
515 unless (exists $entry->{Id}) {
516 my ($id) = get_ids_by_filename($file);
520 push @notqueued, $entry;
525 if (defined $currentdisc && $currentdisc != $entry->{Disc}) {
526 fvwm_cmd("+", "", "Nop");
528 $currentdisc = $entry->{Disc};
530 fvwm_cmd("+", menu_trackname($entry), "Exec",
531 "exec", "$FindBin::Bin/mpdexec.pl",
532 "playid", $entry->{Id});
535 fvwm_cmd("+", "Not in play queue", "Title") if @notqueued;
536 foreach my $entry (@notqueued) {
537 fvwm_cmd("+", menu_trackname($entry));
539 } elsif ($mode eq "artist") {
540 # Create an artist menu.
544 $menu //= "MenuMPDArtist";
546 mpd_exec("playlistfind", "artist", $artist);
551 if (/^(\w+): (.*)$/) {
552 $entry = {} if ($1 eq "file");
553 $matches{$2} //= $entry if ($1 eq "MUSICBRAINZ_ALBUMID");
554 add_track_metadata($entry, $1, $2);
558 my @mbids = sort { datesort(\%matches, $a, $b) } keys %matches;
559 my @files = map { $matches{$_}->{file} } @mbids;
560 my @thumbs = get_item_thumbnails({ small => 1 }, @files);
561 fvwm_cmd("AddToMenu", $menu, "No releases found", "Title") unless @mbids;
563 foreach my $mbid (@mbids) {
564 my $entry = $matches{$mbid};
565 my $thumb = shift @thumbs;
567 my @submenu = make_submenu("$menu-$mbid", "--album-id=$mbid");
568 fvwm_cmd("AddToMenu", $menu,
569 $thumb . fvwm_label_escape($entry->{Album}),
572 } elsif ($mode eq "track") {
573 # Create a title menu.
577 $menu = "MenuMPDTitle" unless defined $menu;
579 # Open and close brackets.
580 my ($ob, $cb) = ("[\[~〜<〈(ー−-]", "[\]~〜>〉)ー−-]");
584 # Deal with specific cases.
585 s/ちいさな(?=ヘミソフィア)//; # ヘミソフィア
586 s/ "mix on air flavor" dear EIKO SHIMAMIYA//; # Spiral wind
587 s/ "So,you need me" Style//; # I need you
588 s/ ::Symphony Second movement:://; # Disintegration
589 s/-\[instrumental\]//; # 青い果実
590 s/ -Practice Track-//; # Fair Heaven
591 s/〜世界で一番アナタが好き〜//; # Pure Heart
593 s/ sora no uta ver.//; # 美しい星
595 s/\s*-remix-$//; # Otherwise "D-THREAD -remix-" doesn't work right.
597 # Deal with titles like "blah (ABC version)".
598 s/\s*$ob.*(style|mix|edit|edition|ver\.?|version|melody|カラオケ)$cb?$//i;
600 # Deal with titles like "blah (without XYZ)".
601 s/\s*$ob\s*((e\.)?piano|english|japanese|inst|tv|without|w\/o|off|back|short|karaoke|game).*//i;
603 # Deal with titles like "blah instrumental".
604 s/\s+(instrumental|off vocal|short|tv)([\s-]+(mix|size|version))?$//i;
605 s/\s+without\s+\w+$//i;
607 # Deal with separate movements in classical pieces.
611 my $_basetitle = $basetitle;
613 $_basetitle =~ s/"/\\"/g;
614 print $sock "playlistsearch title \"$_basetitle\"\n";
619 if (/^(\w+): (.*)$/) {
621 push @titles, $entry if (keys(%$entry) > 0);
628 push @titles, $entry if (keys(%$entry) > 0);
630 { # work around 'use locale' breaking s///i
633 my @thumbs = get_item_thumbnails({ small => 1 },
634 map { $_->{file} } @titles);
635 for (my $i = 0; $i < @titles; $i++) {
636 $titles[$i]->{thumb} = $thumbs[$i];
639 foreach (sort titlesort @titles) {
640 my ($t_file, $t_artist, $t_title, $t_id, $thumb) = (
648 # MPD searches are case-insensitive.
649 next if (!($t_title =~ m/(\P{Latin}|^)\Q$basetitle\E(\P{Latin}|$)/ || $t_title =~ m/\Q$basetitle\E/i));
651 $t_artist = sanitise($t_artist, 1);
652 $t_title = sanitise($t_title, 1);
654 cmd("AddToMenu $menu \"$thumb$t_artist - $t_title\""
655 ." Exec exec $FindBin::Bin/mpdexec.pl"
658 } # end use locale workaround
662 print $sock "close\n";
675 return ($a->{Album} cmp $b->{Album}) if($a->{Album} ne $b->{Album});
676 return ($a->{Artist} cmp $b->{Artist}) if($a->{Artist} ne $b->{Artist});
677 return ($a->{Title} cmp $b->{Title});